Revolution Protocol - deth's results

A protocol to empower communities to raise funds, fairly distribute governance, and maximize their impact in the world.

General Information

Platform: Code4rena

Start Date: 13/12/2023

Pot Size: $36,500 USDC

Total HM: 18

Participants: 110

Period: 8 days

Judge: 0xTheC0der

Id: 311

League: ETH

Collective

Findings Distribution

Researcher Performance

Rank: 47/110

Findings: 5

Award: $113.04

QA:
grade-b

🌟 Selected for report: 1

🚀 Solo Findings: 0

Awards

2.6741 USDC - $2.67

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
sufficient quality report
duplicate-515

External Links

Lines of code

https://github.com/code-423n4/2023-12-revolutionprotocol/blob/d42cc62b873a1b2b44f57310f9d4bbfdd875e8d6/packages/revolution/src/AuctionHouse.sol#L348

Vulnerability details

Impact

There are two scenarios that can occur when an auction is settled.

One is that the auction is completed, the verb is transferred to the bidder, the bid amount transferred to the creators and so on.

The second scenario is when the bidder gets refunded his ETH and the verb is burned.

Taking a look at how we discern if an auction should be refunded we see this:

if (address(this).balance < reservePrice) {
            // If contract balance is less than reserve price, refund to the last bidder
            if (_auction.bidder != address(0)) {
                _safeTransferETHWithFallback(_auction.bidder, _auction.amount);
            }

            // And then burn the Noun
            verbs.burn(_auction.verbId);
        }

We compare the ETH balance of the AuctionHouse to the reservePrice , which can change.

AuctionHouse has only 2 ways to receive ETH, through the constructor when first deployed and through createBid , but there is also a third way to forcefully send ETH to a contract and that’s using selfdestruct .

Knowing this, users can manipulate auctions that should have been refunded, but instead, they will pass and the verb token will be transferred to the bidder.

This is a problem, as it’s manipulation of the state, an auction that should have been refunded and verb token burned, won’t be.

Obviously this shouldn’t happen as if the protocol expects for the auction to be refunded, it shouldn’t all of a sudden succeed and transfer the verb token to the bidder. If the verb token isn’t burned, it won’t change the quorumVotes for any future art pieces inside CultureIndex as the quorumVotes use both erc20VotingToken.totalSupply and erc721VotingToken.totalSupply to calculate the quorumVotes and will potentially transfer the verb token to someone malicious

Another scenario can occur, where a malicious user can do the same attack multiple times in order to stop all refunding and burning of verb tokens that are being auctioned off. This will lead to an inflation of verb tokens total supply and an inflation of the quorumVotes inside CultureIndex.

This breaks the logical flow of _settleAuction potentially leading to inflating the quorumVotes when creating art pieces, as if the token got burned, quorumVotes would be smaller than if it hadn’t been burned.

If done enough times with enough funds, a malicious actor can steal a large amount of voting weight and in order to counteract the fact, the protocol will be forced to reduce the erc721VotingTokenWeight which will affect all other holders of the tokens.

Proof of Concept

Scenario:

  1. An auction is created for an art piece with reserveRatio = 1e18
  2. The auction reaches it’s endTime with the bidder being Alice and the amount being 2e18 .
  3. The protocol decides to bump up the reserveRatio to 3e18
  4. When calling _settleAuction the auction should refund the 2e18 to Alice and burn the verb token.
  5. Alice employs an MEV bot that front-runs _settleAuction , by using a contract that self destructs and sends it’s ETH balance to the AuctionHouse contract.
  6. The contract self destructs, sending 1e18 of ETH to the AuctionHouse
  7. _settleAuction is called and this check fails, as address(this).balance >= reserveRatio
  8. The verb is transferred to Alice, not refunding and burning the verb as the protocol originally expected.
  9. Alice now holds more voting weight than she did before and the quorumVotes stay the same after the _settleAuction while they should have gone down, if the verb got burned as the protocol expected.

Tools Used

Manual Review

Use an internal state variable to keep track of the ETH balance of the contract. Using address(this).balance is a bad practice in general, as it can easily be manipulated.

Assessed type

ETH-Transfer

#0 - c4-pre-sort

2023-12-22T19:15:12Z

raymondfam marked the issue as insufficient quality report

#1 - c4-pre-sort

2023-12-22T19:15:51Z

raymondfam marked the issue as duplicate of #72

#2 - c4-pre-sort

2023-12-22T23:21:09Z

raymondfam marked the issue as sufficient quality report

#3 - c4-pre-sort

2023-12-22T23:21:14Z

raymondfam marked the issue as not a duplicate

#4 - c4-pre-sort

2023-12-22T23:21:24Z

raymondfam marked the issue as duplicate of #515

#5 - c4-judge

2024-01-05T22:06:41Z

MarioPoneder changed the severity to 3 (High Risk)

#6 - c4-judge

2024-01-05T22:07:59Z

MarioPoneder marked the issue as satisfactory

#7 - c4-judge

2024-01-11T18:03:12Z

MarioPoneder changed the severity to 2 (Med Risk)

Awards

9.3879 USDC - $9.39

Labels

bug
2 (Med Risk)
satisfactory
sufficient quality report
duplicate-449

External Links

Lines of code

https://github.com/code-423n4/2023-12-revolutionprotocol/blob/d42cc62b873a1b2b44f57310f9d4bbfdd875e8d6/packages/revolution/src/CultureIndex.sol#L209-L249

Vulnerability details

Impact

Whenever a new piece is created through createPiece , quorumVotes is calculated based on quorumVotesBps and etc20VotingToken.totalSupply and erc721VotingToken.totalSupply .

function createPiece(
        ArtPieceMetadata calldata metadata,
        CreatorBps[] calldata creatorArray
    ) public returns (uint256) {
        uint256 creatorArrayLength = validateCreatorsArray(creatorArray);

        // Validate the media type and associated data
        validateMediaType(metadata);

        uint256 pieceId = _currentPieceId++;

        /// @dev Insert the new piece into the max heap
        maxHeap.insert(pieceId, 0);

        ArtPiece storage newPiece = pieces[pieceId];

        newPiece.pieceId = pieceId;
        newPiece.totalVotesSupply = _calculateVoteWeight(
            erc20VotingToken.totalSupply(),
            erc721VotingToken.totalSupply()
        );
        newPiece.totalERC20Supply = erc20VotingToken.totalSupply();
        newPiece.metadata = metadata;
        newPiece.sponsor = msg.sender;
        newPiece.creationBlock = block.number;
        newPiece.quorumVotes = (quorumVotesBPS * newPiece.totalVotesSupply) / 10_000;

        for (uint i; i < creatorArrayLength; i++) {
            newPiece.creators.push(creatorArray[i]);
        }

        emit PieceCreated(pieceId, msg.sender, metadata, newPiece.quorumVotes, newPiece.totalVotesSupply);

        // Emit an event for each creator
        for (uint i; i < creatorArrayLength; i++) {
            emit PieceCreatorAdded(pieceId, creatorArray[i].creator, msg.sender, creatorArray[i].bps);
        }

        return newPiece.pieceId;
    }

The problem here is the usage of totalSupply in the calculation. The value can easily be manipulated through ERC20TokenEmitter.buyToken .

function buyToken(
        address[] calldata addresses,
        uint[] calldata basisPointSplits,
        ProtocolRewardAddresses calldata protocolRewardsRecipients
    ) public payable nonReentrant whenNotPaused returns (uint256 tokensSoldWad) {
        //prevent treasury from paying itself
        require(msg.sender != treasury && msg.sender != creatorsAddress, "Funds recipient cannot buy tokens");

        require(msg.value > 0, "Must send ether");
        // ensure the same number of addresses and bps
        require(addresses.length == basisPointSplits.length, "Parallel arrays required");

        // Get value left after protocol rewards
        uint256 msgValueRemaining = _handleRewardsAndGetValueToSend(
            msg.value,
            protocolRewardsRecipients.builder,
            protocolRewardsRecipients.purchaseReferral,
            protocolRewardsRecipients.deployer
        );

        //Share of purchase amount to send to treasury
        uint256 toPayTreasury = (msgValueRemaining * (10_000 - creatorRateBps)) / 10_000;

        //Share of purchase amount to reserve for creators
        //Ether directly sent to creators
        uint256 creatorDirectPayment = ((msgValueRemaining - toPayTreasury) * entropyRateBps) / 10_000;
        //Tokens to emit to creators
        int totalTokensForCreators = ((msgValueRemaining - toPayTreasury) - creatorDirectPayment) > 0
            ? getTokenQuoteForEther((msgValueRemaining - toPayTreasury) - creatorDirectPayment)
            : int(0);

        // Tokens to emit to buyers
        int totalTokensForBuyers = toPayTreasury > 0 ? getTokenQuoteForEther(toPayTreasury) : int(0);

        //Transfer ETH to treasury and update emitted
        emittedTokenWad += totalTokensForBuyers;
        if (totalTokensForCreators > 0) emittedTokenWad += totalTokensForCreators;

        //Deposit funds to treasury
        (bool success, ) = treasury.call{ value: toPayTreasury }(new bytes(0));
        require(success, "Transfer failed.");

        //Transfer ETH to creators
        if (creatorDirectPayment > 0) {
            (success, ) = creatorsAddress.call{ value: creatorDirectPayment }(new bytes(0));
            require(success, "Transfer failed.");
        }

        //Mint tokens for creators
        if (totalTokensForCreators > 0 && creatorsAddress != address(0)) {
            _mint(creatorsAddress, uint256(totalTokensForCreators));
        }

        uint256 bpsSum = 0;

        //Mint tokens to buyers

        for (uint256 i = 0; i < addresses.length; i++) {
            if (totalTokensForBuyers > 0) {
                // transfer tokens to address
                _mint(addresses[i], uint256((totalTokensForBuyers * int(basisPointSplits[i])) / 10_000));
            }
            bpsSum += basisPointSplits[i];
        }

        require(bpsSum == 10_000, "bps must add up to 10_000");

        emit PurchaseFinalized(
            msg.sender,
            msg.value,
            toPayTreasury,
            msg.value - msgValueRemaining,
            uint256(totalTokensForBuyers),
            uint256(totalTokensForCreators),
            creatorDirectPayment
        );

        return uint256(totalTokensForBuyers);
    }

The function mints new tokens to several addresses, which increases the total supply of the token. The function has no access control and can be called by anyone, freely.

The protocol team has stated:

this is somewhat expected, but i'm not sure if it throws off the economics of the system, but ideally most people are interfacing with buyToken through the AuctionHouse, commerce contracts, or minting contracts, not buying directly.

It doesn’t break the economics of the system as everything is calculated correctly, but the functionality can be used maliciously by users to influence the quorumVotes for a piece, making it much harder and in some cases impossible to pass.

Proof of Concept

Scenario:

We are assuming erc20VotingToken.totalSupply == 100e18 , quorumVotesBps == 6000 and that Alice and Bob hold a total of 60e18 of the voting weight, which is enough to make the piece eligible to be dropped. For simplicity we are assuming erc721VotingToken.totalSupply == 0.

  1. Alice calls createPiece . The piece has to reach 60e18 to pass it’s quorumVotes in order for the piece to be eligible to be dropped.
  2. Charlie front runs Alice and calls buyToken and a 100e18 new tokens are minted.
  3. Alice’s tx goes through and now:

erc20VotingToken.totalSupply == 200e18 and quorumVotes == 120e18

  1. At this point even if Alice and Bob vote for the piece, it can never reach quorumVotes as they only hold 60e18 voting weight, which is around 30% of the entire voting weight.
  2. Charlie manipulated the quorumVotes , making it impossible for Alice’s piece to be dropped.

Since buyToken is supposed to be used through different contracts, as the sponsor stated, buyToken should have some access control to stop users from straight up buying tokens.

Note that there is no financial loss for Charlie, as he just “trades” his ETH for tokens.

Tools Used

Manual Review

Add access control to buyToken that only allows specific contracts to interface with the function.

Assessed type

Access Control

#0 - c4-pre-sort

2023-12-22T19:18:32Z

raymondfam marked the issue as insufficient quality report

#1 - c4-pre-sort

2023-12-22T19:18:45Z

raymondfam marked the issue as primary issue

#2 - raymondfam

2023-12-22T19:18:55Z

Intended design.

#3 - c4-pre-sort

2023-12-22T19:38:06Z

raymondfam marked the issue as sufficient quality report

#4 - c4-pre-sort

2023-12-22T19:38:28Z

raymondfam marked the issue as duplicate of #449

#5 - c4-judge

2024-01-06T15:55:53Z

MarioPoneder marked the issue as satisfactory

Awards

9.3879 USDC - $9.39

Labels

bug
2 (Med Risk)
high quality report
primary issue
satisfactory
selected for report
sponsor acknowledged
M-03

External Links

Lines of code

https://github.com/code-423n4/2023-12-revolutionprotocol/blob/d42cc62b873a1b2b44f57310f9d4bbfdd875e8d6/packages/revolution/src/CultureIndex.sol#L519-L534

Vulnerability details

Impact

CultureIndex is responsible for the creation, voting and dropping (auctioning off) art pieces.

Let’s focus on dropTopVotedPiece . The function is used by the AuctionHouse to take the top voted art piece, drop it and auction it off.

function dropTopVotedPiece() public nonReentrant returns (ArtPiece memory) {
        require(msg.sender == dropperAdmin, "Only dropper can drop pieces");

        ICultureIndex.ArtPiece memory piece = getTopVotedPiece(); 
        require(totalVoteWeights[piece.pieceId] >= piece.quorumVotes, "Does not meet quorum votes to be dropped.");
        

        //set the piece as dropped
        pieces[piece.pieceId].isDropped = true;

        //slither-disable-next-line unused-return
        maxHeap.extractMax();

        emit PieceDropped(piece.pieceId, msg.sender);

        return pieces[piece.pieceId];
    }

Notice how the top voted piece is retrieved and then we check if totalVoteWeight > quorumVotes . This is used to check if the piece has reached it’s quorum, which is cached during creation.

newPiece.pieceId = pieceId;
        newPiece.totalVotesSupply = _calculateVoteWeight(
            erc20VotingToken.totalSupply(), 
            erc721VotingToken.totalSupply() 
        );
        newPiece.totalERC20Supply = erc20VotingToken.totalSupply(); 
        newPiece.metadata = metadata;
        newPiece.sponsor = msg.sender; 
        newPiece.creationBlock = block.number
        newPiece.quorumVotes = (quorumVotesBPS * newPiece.totalVotesSupply) / 10_000;

Notice that for quorumVotes we use the erc20VotingToken.totalSupply , erc721VotingToken.totalSupply and quorumVotesBPS.

Knowing all this, a malicious user can do the following to break dropTopVotedPiece under certain conditions.

He will call ERC20TokenEmitter#buyToken to buy the voting token, which in turn will inflate the erc20VotingToken.totalSupply , which will also increase the newPiece.quorumVotes .

After this he will create a new bogus art piece and its quorum votes will be inflated. (We are assuming that no one wants to vote for the art piece as it’s a bogus/fake art piece)

He will then vote for his new piece, making it the top voted piece, but the piece won’t reach it’s quorum so it cannot be dropped.

At this point one of the following can occur:

  1. Users will wait for a new piece to be created and become top voted, dropped and auctioned off. The protocol might work normally at this point, but once the bogus/fake piece becomes top voted again, it still can’t be dropped. If the quorum for the fake piece isn’t reached, it can never be dropped, meaning that all pieces that have less votes than it and are eligible to be dropped (they reached their quorum) can never be reached, since the fake piece can technically stay there forever.
  2. Users will be forced to vote for the bogus/fake piece in order to push it over it’s quorum so it can be dropped. Obviously this isn’t ideal as it requires to persuade users to spend gas to vote for something that they don’t want to, just so the protocol can continue working correctly. After the bogus piece gets dropped it needs to go into an auction, which has a duration so users will also have to wait for the auction to terminate, get settled and then the protocol can continue normally, which will waste time and increase the duration of the DoS.
  3. Users that voted for a piece that is eligible to be dropped, but doesn’t have more votes than the fake piece, will be forced to create a new piece and start voting on all over again. This isn’t ideal, as the quorumVotes for the piece will be different and it isn’t even sure that the new piece will be accepted under the new market conditions.

All 3 of the scenarios are bad for the normal execution of the protocol and especially under scenario 1, can leave pieces to just rot, as they can never be reached.

Note that the malicious user that does the attack, doesn’t lose any funds, as he is just paying to buy the voting token, also the attack scenario can happen on it’s own naturally, without the use of buyToken , but it will still lead to 1 of the 3 followup scenarios.

This scenario can happen naturally, without anyone being malicious and the attack doesn't rely on the fact that anyone can call buyToken , it just makes it easier.

The sponsor has stated that in the future there will contracts that interface with buyToken , so even if access control is added to the function, it still won't fix the issue.

this is somewhat expected, but i'm not sure if it throws off the economics of the system, but ideally most people are interfacing with buyToken through the AuctionHouse, commerce contracts, or minting contracts, not buying directly.

Proof of Concept

Create a folder inside revolution/test called CustomTests , create a new file called CustomTests.t.sol , paste the following inside and run forge test --mt testTopVotedPieceCantReachQuorum -vvvv

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

import "forge-std/console.sol";
import { Test } from "forge-std/Test.sol";
import { unsafeWadDiv } from "../../src/libs/SignedWadMath.sol";
import { ERC20TokenEmitter } from "../../src/ERC20TokenEmitter.sol";
import { IERC20TokenEmitter } from "../../src/interfaces/IERC20TokenEmitter.sol";
import { NontransferableERC20Votes } from "../../src/NontransferableERC20Votes.sol";
import { RevolutionProtocolRewards } from "@collectivexyz/protocol-rewards/src/RevolutionProtocolRewards.sol";
import { wadDiv } from "../../src/libs/SignedWadMath.sol";
import { IRevolutionBuilder } from "../../src/interfaces/IRevolutionBuilder.sol";
import { RevolutionBuilderTest } from "../RevolutionBuilder.t.sol";
import { INontransferableERC20Votes } from "../../src/interfaces/INontransferableERC20Votes.sol";
import { ERC1967Proxy } from "../../src/libs/proxy/ERC1967Proxy.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { CultureIndex } from "../../src/CultureIndex.sol";
import { ICultureIndex } from "../../src/interfaces/ICultureIndex.sol";

contract ERC20TokenEmitterTest is RevolutionBuilderTest {
    event Log(string, uint);

    // 1,000 tokens per day is the target emission
    uint256 tokensPerTimeUnit = 1_000;

    uint256 expectedVolume = tokensPerTimeUnit * 1e18;

    string public tokenNamePrefix = "Vrb";

    function setUp() public override {
        super.setUp();
        super.setMockParams();

        super.setERC721TokenParams("Mock", "MOCK", "https://example.com/token/", tokenNamePrefix);

        int256 oneFullTokenTargetPrice = 1 ether;

        int256 priceDecayPercent = 1e18 / 10;

        super.setERC20TokenEmitterParams(
            oneFullTokenTargetPrice,
            priceDecayPercent,
            int256(1e18 * tokensPerTimeUnit),
            creatorsAddress
        );

        super.deployMock();

        vm.deal(address(0), 100000 ether);
    }

    function testTopVotedPieceCantReachQuorum() public {
        // Setup no fees for the creator for simplicity of the test and the values
        vm.startPrank(erc20TokenEmitter.owner());
        erc20TokenEmitter.setCreatorsAddress(address(1));
        erc20TokenEmitter.setCreatorRateBps(0);
        erc20TokenEmitter.setEntropyRateBps(0);
        vm.stopPrank();

        // Set quorumVotesBps to 6000 (60%)
        vm.prank(cultureIndex.owner());
        cultureIndex._setQuorumVotesBPS(6000);

        // Setup Alice, Bob and Charlie
        address alice = address(9);
        vm.deal(alice, 100000 ether);
        address bob = address(10);
        vm.deal(bob, 100000 ether);
        address charlie = address(11);
        vm.deal(charlie, 100000 ether);

        // Bob buys tokens
        address[] memory recipients = new address[](1);
        recipients[0] = bob;
        uint256[] memory bps = new uint256[](1);
        bps[0] = 10_000;

        vm.prank(bob);
        erc20TokenEmitter.buyToken{ value: 10e18 }(
            recipients,
            bps,
            IERC20TokenEmitter.ProtocolRewardAddresses({
                builder: address(0),
                purchaseReferral: address(0),
                deployer: address(0)
            })
        );

        // Charlie buys tokens
        recipients = new address[](1);
        recipients[0] = charlie;
        bps = new uint256[](1);
        bps[0] = 10_000;

        vm.prank(charlie);
        erc20TokenEmitter.buyToken{ value: 10e18 }(
            recipients,
            bps,
            IERC20TokenEmitter.ProtocolRewardAddresses({
                builder: address(0),
                purchaseReferral: address(0),
                deployer: address(0)
            })
        );

        // Alice buys tokens
        recipients = new address[](1);
        recipients[0] = alice;
        bps = new uint256[](1);
        bps[0] = 10_000;

        vm.prank(alice);
        erc20TokenEmitter.buyToken{ value: 10e18 }(
            recipients,
            bps,
            IERC20TokenEmitter.ProtocolRewardAddresses({
                builder: address(0),
                purchaseReferral: address(0),
                deployer: address(0)
            })
        );

        vm.roll(block.number + 10);

        // Bob creates a piece
        vm.prank(bob);
        uint256 bobsPiece = createDefaultArtPiece(bob);

        vm.roll(block.number + 10);

        // Bob votes for his piece
        vm.prank(bob);
        cultureIndex.vote(bobsPiece);

        // Bob's piece is the top voted one
        assertEq(cultureIndex.topVotedPieceId(), bobsPiece);

        // Bobs piece hasn't passed it's quorum
        ICultureIndex.ArtPiece memory piece = cultureIndex.getTopVotedPiece();
        assertLt(cultureIndex.totalVoteWeights(piece.pieceId), piece.quorumVotes);
        
        // Alice buys tokens again
        recipients = new address[](1);
        recipients[0] = alice;
        bps = new uint256[](1);
        bps[0] = 10_000;

        vm.prank(alice);
        erc20TokenEmitter.buyToken{ value: 15e18 }(
            recipients,
            bps,
            IERC20TokenEmitter.ProtocolRewardAddresses({
                builder: address(0),
                purchaseReferral: address(0),
                deployer: address(0)
            })
        );
        
        vm.roll(block.number + 1);

        // Alice creates a piece
        vm.prank(alice);
        uint256 alicesPiece = createDefaultArtPiece(alice);

        vm.roll(block.number + 1);
        // Alice votes on her piece next block
        // She votes enough to be the top voted piece, but not enough to pass her quorum
        vm.prank(alice);
        cultureIndex.vote(alicesPiece);
        
        // Now Alice's piece is the top voted one
        assertEq(cultureIndex.topVotedPieceId(), alicesPiece);

        // Her piece is the top voted one, but hasn't reached her quorum
        piece = cultureIndex.getTopVotedPiece();
        assertLt(cultureIndex.totalVoteWeights(piece.pieceId), piece.quorumVotes);
        assertEq(piece.pieceId, alicesPiece);

        // Alice's piece cannot be dropped
        vm.startPrank(cultureIndex.dropperAdmin());
        vm.expectRevert();
        cultureIndex.dropTopVotedPiece();

        // At this point Alice's piece will stay top voted,
        // Since Bob and Charlie don't want to vote on her art piece
        // Even if they did, this way an unpopular art piece might be forced into
        // being auctioned off in the AuctionHouse, which will DoS the users of the protocol for even longer
    }

    function createArtPiece(
        string memory name,
        string memory description,
        ICultureIndex.MediaType mediaType,
        string memory image,
        string memory text,
        string memory animationUrl,
        address creatorAddress,
        uint256 creatorBps
    ) internal returns (uint256) {
        ICultureIndex.ArtPieceMetadata memory metadata = ICultureIndex.ArtPieceMetadata({
            name: name,
            description: description,
            mediaType: mediaType,
            image: image,
            text: text,
            animationUrl: animationUrl
        });

        ICultureIndex.CreatorBps[] memory creators = new ICultureIndex.CreatorBps[](1);
        creators[0] = ICultureIndex.CreatorBps({ creator: creatorAddress, bps: creatorBps });

        return cultureIndex.createPiece(metadata, creators);
    }
    
    function createDefaultArtPiece(address creator) public returns (uint256) {
        return
            createArtPiece(
                "Mona Lisa",
                "A masterpiece",
                ICultureIndex.MediaType.IMAGE,
                "ipfs://legends",
                "",
                "",
                creator,
                10000
            );
    }
}

Tools Used

Manual Review Foundry

There isn't a very elegant way to fix this, as this is how a Max Heap is supposed to function. One way is to add an admin function that can forcefully drop a piece from the Max Heap.

Assessed type

DoS

#0 - c4-pre-sort

2023-12-22T19:35:42Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2023-12-22T19:35:55Z

raymondfam marked the issue as primary issue

#2 - raymondfam

2023-12-24T15:10:03Z

Duplicated submissions come with various/opposing scenarios originating from the same root cause.

#3 - c4-pre-sort

2023-12-24T15:12:12Z

raymondfam marked the issue as high quality report

#4 - rocketman-21

2024-01-04T23:41:46Z

one potential solution here is to let the DAO vote to axe the vote weight of malicious pieces that attempt to do this

in any case, assuming most actors in the community are good, the artists can just garner more votes than the malicious piece to bypass this issue

#5 - c4-sponsor

2024-01-04T23:41:50Z

rocketman-21 (sponsor) acknowledged

#6 - MarioPoneder

2024-01-06T15:54:01Z

Imho we cannot rely on most community members acting in good faith 100% of the time to prevent this from happening, therefore I am leaning more towards Medium severity since the protocol and good faith actors can be negatively impacted by this attack.

Furthermore, there is currently no way to easily circumvent this problem.

#7 - c4-judge

2024-01-06T15:54:16Z

MarioPoneder marked the issue as satisfactory

#8 - c4-judge

2024-01-06T16:06:17Z

MarioPoneder marked the issue as selected for report

#9 - rocketman-21

2024-01-09T16:04:46Z

right @MarioPoneder but it assumes malicious vote weight > the remaining vote weight of "good" active users > quorum.

It assumes these "good users" are unable to vote for a good piece to reach the top voted piece spot

imo there could be some severe edge cases where voter apathy paired with a low quorum could make this possible, but with a sufficient active voting base and a solid quorum, I don't think the assumption that a malicious user will always be able to have the largest amount of vote weight vs. everyone else actually holds up in a real world scenario?

if quorum is low and voter turnout is low that's a different story

#10 - rocketman-21

2024-01-09T16:06:55Z

it's a balancing act - if the quorum is too high this can happen in any case. I think this is fair on second thought in some edge cases, just not sure how to fix

#11 - osmanozdemir1

2024-01-10T00:00:10Z

Hi @MarioPoneder Thanks for judging this contest.

The explained scenario can be produced as PoC but it doesn't realistic for an active protocol with tens/hundreds of users.

For this to happen:

  1. Fake art piece must be top voted.
  2. But it also must not reach the quorum.
  3. And other pieces must have less votes than the fake one, but also reach to the quorum.

Besides,

Note that the malicious user that does the attack, doesn’t lose any funds, as he is just paying to buy the voting token

This implies the attack is not costly but it is incorrect since the voting token is not transferable. The attacker can not sell or swap these tokens. "Just paying to buy voting token" is in fact a huge cost for the token that worths nothing in terms of money. Also it needs to be more than everyone else's total voting power to actually perform this attack. If community can surpass the attacker's fake token's vote count, the attacker must create another fake art piece and must buy additional voting power.

#12 - MarioPoneder

2024-01-11T17:57:33Z

Thanks everyone for their input!

I agree that the attack path is rather hand-wavy. However, the described problem can also occur naturally without an attacker.
See report:

This scenario can happen naturally, without anyone being malicious and the attack doesn't rely on the fact that anyone can call buyToken , it just makes it easier.

See sponsor:

it's a balancing act - if the quorum is too high this can happen in any case. I think this is fair on second thought in some edge cases, just not sure how to fix

As the report also comes with a PoC (even though with an attacker) that proves that the protocol can be brought into this state, maintaining Medium severity seems appropriate.

#13 - rocketman-21

2024-01-17T01:22:38Z

https://github.com/collectivexyz/revolution-protocol/pull/89

fix here to not pause the auction fully, but let the community try to garner enough votes, and adds createAuction function

Findings Information

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
sufficient quality report
duplicate-409

Awards

51.1381 USDC - $51.14

External Links

Lines of code

https://github.com/code-423n4/2023-12-revolutionprotocol/blob/d42cc62b873a1b2b44f57310f9d4bbfdd875e8d6/packages/revolution/src/CultureIndex.sol#L313

Vulnerability details

Impact

When creating an art piece through CultureIndex.createPiece one very important property is cached, quorumVotes.

It is calculated like so:

newPiece.totalVotesSupply = _calculateVoteWeight(
            erc20VotingToken.totalSupply(), 
            erc721VotingToken.totalSupply() 
        );
        newPiece.quorumVotes = (quorumVotesBPS * newPiece.totalVotesSupply) / 10_000;

You can see that it uses quorumVotesBPS and newPieceTotalVotesSupply .

Based on quorumVotes we can see if a art piece is eligible to be dropped or not.

Let’s examine how we vote for a piece:

function _vote(uint256 pieceId, address voter) internal {
        require(pieceId < _currentPieceId, "Invalid piece ID");
        require(voter != address(0), "Invalid voter address");
        require(!pieces[pieceId].isDropped, "Piece has already been dropped");
        require(!(votes[pieceId][voter].voterAddress != address(0)), "Already voted");

        uint256 weight = _getPastVotes(voter, pieces[pieceId].creationBlock); 
        require(weight > minVoteWeight, "Weight must be greater than minVoteWeight");

        votes[pieceId][voter] = Vote(voter, weight);
        totalVoteWeights[pieceId] += weight;

        uint256 totalWeight = totalVoteWeights[pieceId];

        // TODO add security consideration here based on block created to prevent flash attacks on drops?
        maxHeap.updateValue(pieceId, totalWeight); 
        emit VoteCast(pieceId, voter, weight, totalWeight);
    }

You’ll notice that we get the voting weight through _getPastVotes

function _getPastVotes(address account, uint256 blockNumber) internal view returns (uint256) {
        return
            _calculateVoteWeight(
                erc20VotingToken.getPastVotes(account, blockNumber),
                erc721VotingToken.getPastVotes(account, blockNumber) //@audit-issue the NFT can be transferred to double vote with it
            );
    }

The function getPastVotes is an OZ function inside VotesUpgradeable :

function getPastVotes(address account, uint256 timepoint) public view virtual returns (uint256) {
        VotesStorage storage $ = _getVotesStorage();
        uint48 currentTimepoint = clock();
        if (timepoint >= currentTimepoint) {
            revert ERC5805FutureLookup(timepoint, currentTimepoint);
        }
        return $._delegateCheckpoints[account].upperLookupRecent(SafeCast.toUint48(timepoint));
    }

You’ll notice the if statement reverts if we try to vote inside the same block, this is to prevent the popular double voting governance attack.

Knowing this you might assume that a user can’t vote in the same block as when a piece is created, and you will be correct, but he can still vote with a checkpoint/snapshot of his voting weight that was created in the same block as the piece was created.

 uint256 weight = _getPastVotes(voter, pieces[pieceId].creationBlock);

Notice how we pass in pieces[pieceId].creationBlock for the timepoint inside _getPastVotes .

Because we are using creationBlock , the following can occur:

  1. Alice calls createPiece and erc20VotingToken.totalSupply == 10e18 and newPiece.totalVotesSupply == 10e18 (we are excluding the erc721VotingToken in the scenario for simplicity)
  2. Alice back-runs her createPiece and calls ERC20TokenEmitter.buyToken and mints 20e18 tokens to herself
  3. Alice waits 1 block and votes for her piece.
  4. At this point newPiece.totalVotesSupply == 10e18 , but the total weight that can be used to vote is 30e18. Because of this quorumVotes can much easily be reached, because the new 20e18 voting weight hasn’t been accounted for when the piece was created.

Something to note is that the same scenario can occur when _settleAuction is called. A malicious creator can reenter through _safeTransferETHWithFallback and call createPiece . The next line inside _settleAuction calls buyToken , so the effect will be the same, although more difficult to pull of and not as effective.

Note that the problem here is that we use creationBlock as the timepoint to retrieve the voting weight, not that a user can freely call buyToken

The bellow PoC showcases the scenario with the back-run, as it’s more effective and simple to showcase, and in both scenarios, the root cause is the same. (We are using _getPastVotes(voter, pieces[pieceId].creationBlock); instead of _getPastVotes(voter, pieces[pieceId].creationBlock - 1);)

Proof of Concept

Paste the following inside Voting.t.sol and run forge test --mt testBackRunningCreatePiece -vvvv

function testBackRunningCreatePiece() public {
        // Note: the values in the assertions are not exactly 10e18, 20е18 etc
        // because of the different fees that are taken into account, 
        // but they do not affect the test or the exploit in any way.

        vm.stopPrank();
        vm.prank(cultureIndex.owner());
        cultureIndex._setQuorumVotesBPS(6000); // Set to 60%
        // Setup Alice and Bob
        address alice = address(9);
        vm.deal(alice, 100000 ether);
        address bob = address(10);
        vm.deal(bob, 100000 ether);

        // Alice buys tokens
        address[] memory recipients = new address[](1);
        recipients[0] = alice;
        uint256[] memory bps = new uint256[](1);
        bps[0] = 10_000;

        vm.prank(alice);
        erc20TokenEmitter.buyToken{ value: 10e18 }(
            recipients,
            bps,
            IERC20TokenEmitter.ProtocolRewardAddresses({
                builder: address(0),
                purchaseReferral: address(0),
                deployer: address(0)
            })
        );

        // Bob buys tokens
        recipients = new address[](1);
        recipients[0] = bob;
        bps = new uint256[](1);
        bps[0] = 10_000;

        vm.prank(bob);
        erc20TokenEmitter.buyToken{ value: 10e18 }(
            recipients,
            bps,
            IERC20TokenEmitter.ProtocolRewardAddresses({
                builder: address(0),
                purchaseReferral: address(0),
                deployer: address(0)
            })
        );

        vm.roll(block.number + 1);

        // Alice creates a piece
        vm.prank(alice);
        uint256 artPieceId = createDefaultArtPiece();

        // We can see that totalERC20Supply == 20e18
        ICultureIndex.ArtPiece memory piece = cultureIndex.getTopVotedPiece();
        assertEq(piece.totalERC20Supply, 19479995726861551000);

        // Alice back runs createPiece and buys 20e18 tokens
        recipients = new address[](1);
        recipients[0] = alice;
        bps = new uint256[](1);
        bps[0] = 10_000;

        vm.prank(alice);
        erc20TokenEmitter.buyToken{ value: 20e18 }(
            recipients,
            bps,
            IERC20TokenEmitter.ProtocolRewardAddresses({
                builder: address(0),
                purchaseReferral: address(0),
                deployer: address(0)
            })
        );

        // Alice waits 1 block to vote
        vm.roll(block.number + 1);

        // Alice votes on the piece
        vm.prank(alice);
        cultureIndex.vote(artPieceId);

        // You can see that totalERC20Supply is still 20e18
        piece = cultureIndex.getTopVotedPiece();
        assertEq(piece.totalERC20Supply, 19479995726861551000);

        // But totalVotingWeight is 30e18, which is more weight
        // than what should be usable to vote for this piece
        assertEq(cultureIndex.totalVoteWeights(piece.pieceId), 29185091933615964000);

        // Alice should have only 10e18 voting weight
        // and she shouldn't be able to push the piece over the 60% quorum threshold,
        // but she did
        assertGt(cultureIndex.totalVoteWeights(piece.pieceId), piece.quorumVotes);
    }

Tools Used

Manual Review Foundry

Change this line _getPastVotes(voter, pieces[pieceId].creationBlock); to

_getPastVotes(voter, pieces[pieceId].creationBlock - 1);

Assessed type

Other

#0 - c4-pre-sort

2023-12-22T19:02:36Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2023-12-22T19:02:59Z

raymondfam marked the issue as duplicate of #260

#2 - c4-pre-sort

2023-12-24T05:40:00Z

raymondfam marked the issue as duplicate of #409

#3 - c4-judge

2024-01-05T22:38:11Z

MarioPoneder changed the severity to 2 (Med Risk)

#4 - c4-judge

2024-01-05T22:38:57Z

MarioPoneder marked the issue as satisfactory

Findings Information

🌟 Selected for report: bart1e

Also found by: 00xSEV, 0xAsen, 0xDING99YA, Timenov, Udsen, _eperezok, bart1e, deth, fnanni, ke1caM, nmirchev8, peanuts, shaka

Labels

bug
2 (Med Risk)
satisfactory
sufficient quality report
duplicate-93

Awards

42.484 USDC - $42.48

External Links

Lines of code

https://github.com/code-423n4/2023-12-revolutionprotocol/blob/d42cc62b873a1b2b44f57310f9d4bbfdd875e8d6/packages/revolution/src/AuctionHouse.sol#L419-L443

Vulnerability details

Impact

When an art piece is created, the user creating the piece can set addresses as creators, up to a maximum of 100 addresses.

Whenever an auction is getting settled, two scenarios can occur:

  1. The bidder gets refunded his ETH and the verb token gets burned.
  2. The verb is transferred to the bidder and the ETH amount is transferred to different addresses, tokens are bought and so on.

The second scenario involves sending a percentage of the bidder’s amount to the creators that were mentioned above, using _safeTransferETHWithFallback.

//Transfer creator's share to the creator, for each creator, and build arrays for erc20TokenEmitter.buyToken
                if (creatorsShare > 0 && entropyRateBps > 0) {
                    for (uint256 i = 0; i < numCreators; i++) {
                        ICultureIndex.CreatorBps memory creator = verbs.getArtPieceById(_auction.verbId).creators[i];
                        vrgdaReceivers[i] = creator.creator; 
                        vrgdaSplits[i] = creator.bps; 

                        //Calculate paymentAmount for specific creator based on BPS splits - same as multiplying by creatorDirectPayment
                        uint256 paymentAmount = (creatorsShare * entropyRateBps * creator.bps) / (10_000 * 10_000);
                        ethPaidToCreators += paymentAmount;

                        //Transfer creator's share to the creator
                        _safeTransferETHWithFallback(creator.creator, paymentAmount); 
                    }
                }
function _safeTransferETHWithFallback(address _to, uint256 _amount) private {
        // Ensure the contract has enough ETH to transfer
        if (address(this).balance < _amount) revert("Insufficient balance"); 

        // Used to store if the transfer succeeded
        bool success;

        assembly {
            // Transfer ETH to the recipient
            // Limit the call to 50,000 gas
            success := call(50000, _to, _amount, 0, 0, 0, 0) 
        }

        // If the transfer failed:
        if (!success) {
            // Wrap as WETH
            IWETH(WETH).deposit{ value: _amount }();

            // Transfer WETH instead
            //@audit-issue there might not be enough WETH in the contract
            bool wethSuccess = IWETH(WETH).transfer(_to, _amount); 

            // Ensure successful transfer
            if (!wethSuccess) revert("WETH transfer failed"); 
        }
    }

We loop through all creators and then attempt to send them ETH with a low level YUL call, if that fails WETH is transferred instead.

Notice that the YUL call, limits the gas to only 50_000. This is so that the external call doesn’t consume all the gas and brick the function, but it can still be exploited maliciously.

Proof of Concept

Let’s take a look at an example:

  1. An art piece gets created. The creators array gets populated up to it’s limit of 100. Every single address is the same, a contract address that consumes all gas in it’s receive/fallback functions.
  2. The piece gets auctioned off and someone calls settleAuction
  3. Once we reach the line, where it’s time to pay the creators shares, each time the YUL call gets executed, the to address will consume all the gas.
  4. The to address consumes all gas 100 times, consuming a total of 5m gas, which is ~700$ on Ethereum.
  5. The caller of settleAuction has to pay at a minimum 700$ for the tx. This doesn’t account the extra loop in buyToken where we again loop through all 100 addresses to mint tokens for them.
//Mint tokens for creators
        if (totalTokensForCreators > 0 && creatorsAddress != address(0)) {
            _mint(creatorsAddress, uint256(totalTokensForCreators));
        }

In total the caller of settleAuction has to pay a minimum of 700$ for a simple tx.

There is no limit to how many times this can occur as there is nothing stopping users from maliciously creating pieces that do this sort of griefing.

Tools Used

Manual Review

Decrease MAX_NUM_CREATORS to less. 10-20 seems like a logical maximum for the number of creators an NFT can have.

Assessed type

Other

#0 - c4-pre-sort

2023-12-23T00:50:41Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2023-12-23T00:50:50Z

raymondfam marked the issue as duplicate of #93

#2 - c4-pre-sort

2023-12-24T14:36:05Z

raymondfam marked the issue as duplicate of #195

#3 - MarioPoneder

2024-01-06T13:25:45Z

#4 - c4-judge

2024-01-06T13:25:51Z

MarioPoneder marked the issue as partial-25

#5 - c4-judge

2024-01-11T18:21:14Z

MarioPoneder marked the issue as not a duplicate

#6 - c4-judge

2024-01-11T18:40:00Z

MarioPoneder marked the issue as duplicate of #93

#7 - c4-judge

2024-01-11T18:40:04Z

MarioPoneder marked the issue as satisfactory

Awards

7.359 USDC - $7.36

Labels

bug
downgraded by judge
grade-b
insufficient quality report
QA (Quality Assurance)
duplicate-112
Q-12

External Links

Lines of code

https://github.com/code-423n4/2023-12-revolutionprotocol/blob/d42cc62b873a1b2b44f57310f9d4bbfdd875e8d6/packages/revolution/src/AuctionHouse.sol#L171-L200 https://github.com/code-423n4/2023-12-revolutionprotocol/blob/d42cc62b873a1b2b44f57310f9d4bbfdd875e8d6/packages/revolution/src/AuctionHouse.sol#L336-L414

Vulnerability details

Impact

AuctionHouse implements a special timeBuffer , which is used to extend an auction by it, if a bid is created during said timeBuffer .

function createBid(uint256 verbId, address bidder) external payable override nonReentrant {
        IAuctionHouse.Auction memory _auction = auction;

        //require bidder is valid address
        require(bidder != address(0), "Bidder cannot be zero address");
        require(_auction.verbId == verbId, "Verb not up for auction");
        //slither-disable-next-line timestamp
        require(block.timestamp < _auction.endTime, "Auction expired");
        require(msg.value >= reservePrice, "Must send at least reservePrice");
        require(
            msg.value >= _auction.amount + ((_auction.amount * minBidIncrementPercentage) / 100),
            "Must send more than last bid by minBidIncrementPercentage amount"
        );

        address payable lastBidder = _auction.bidder;

        auction.amount = msg.value;
        auction.bidder = payable(bidder);

        // Extend the auction if the bid was received within `timeBuffer` of the auction end time
        bool extended = _auction.endTime - block.timestamp < timeBuffer;
        if (extended) auction.endTime = _auction.endTime = block.timestamp + timeBuffer;

        // Refund the last bidder, if applicable
        if (lastBidder != address(0)) _safeTransferETHWithFallback(lastBidder, _auction.amount);

        emit AuctionBid(_auction.verbId, bidder, msg.sender, msg.value, extended);

        if (extended) emit AuctionExtended(_auction.verbId, _auction.endTime);
    }

If the timeBuffer is 10 minutes, if someone bids in the last 10 minutes of the auction, the auction endTime will be extended by 10 minutes.

This is implemented to give a fair chance for all bidders to big on the auction, if the auction is especially competitive.

The protocol also plans to deploy to Base and Optimism.

  • Ethereum (targeting Base and Optimism L2s)

Transactions on Optimism and Base are much cheaper than Ethereum, which opens up a rare attack vector: block stuffing.

A malicious user stuffs an entire block with dummy transactions that consume the entire block gas limit, which is 30m gas on Optimism or ~7$ worth of gas, basically, it’s extremely cheap.

If a user is the current bidder and there is only the timeBuffer left (15 minutes for example), he can block stuff for 15 minutes straight to win the auction.

Let’s make some quick calculations:

timeBuffer == 15 minutes , this is the value used in the tests, so we’ll use it here.

15 minutes = 900 seconds

Optimism creates a block every 2 seconds, so in 15 minutes, there will be 450 blocks created on Optimism.

1 block = 30m gas, so 450 blocks will have 13.5b gas, which is ~3000 $.

Considering that the protocol auctions off NFT’s which can be sold for huge amounts (https://www.masoative.com/post/most-expensive-nft), spending an extra 3000$ to potentially win hundreds of thousands, even millions is a pretty lucrative deal.

The attack can get cheaper or more expensive, depending on the timebuffer .

Proof of Concept

Example:

  1. An art piece is created through the CultureIndex
  2. It gets dropped and the auction for it begins.
  3. Users continue bidding, until a whale becomes the top bidder.
  4. Only 15 minutes are left until the end of the auction (timeBuffer) and the whale wants to 100% win it.
  5. He block stuffs 450 blocks, spending an extra 3000$ to make sure he wins the auction.
  6. After the auction ends, he calls settleAuction which transfers the verb token to him, after which he can sell to make a profit.

The likelihood of this scenario is not uncommon at all, there have been several instances of block stuffing attacks where malicious actors used the technique to win lotteries/auctions etc. Considering how expensive some NFT’s are, the scenario is 100% possible.

You can read a bit more about block stuffing and how it was used to win a lottery here. https://medium.com/hackernoon/the-anatomy-of-a-block-stuffing-attack-a488698732ae

You can input the gas values here to see the current prices. https://www.cryptoneur.xyz/en/gas-fees-calculator

Tools Used

Manual Review

I’m not sure what to recommend as there is no way to stop this sort of attack.

One possible way is to make timeBuffer much larger to make a block stuffing attack more expensive.

Assessed type

Other

#0 - c4-pre-sort

2023-12-23T00:28:37Z

raymondfam marked the issue as insufficient quality report

#1 - c4-pre-sort

2023-12-23T00:28:48Z

raymondfam marked the issue as duplicate of #112

#2 - c4-judge

2024-01-06T20:48:13Z

MarioPoneder changed the severity to QA (Quality Assurance)

#3 - c4-judge

2024-01-06T20:49:34Z

MarioPoneder marked the issue as grade-c

#4 - 0xdeth

2024-01-09T16:42:17Z

Hi @MarioPoneder,

This issue is not a duplicate of #112.

#112 uses the extend functionality to win the bid, while this issue uses block stuffing to unfairly win the bid.

I have showcased how easily and relatively cheaply someone can block stuff for enough time to win an art piece that can easily cover the attackers cost, during the block stuff.

I have also showcased how block stuffing was used in the past in the exact same way: the attacker used block stuffing to win a lottery by simply blocking other users from using it. This issue is exactly the same, but inside an auction, not a lottery.

Because of this I believe this is a valid Medium severity issue.

Cheers and thanks for your time.

#5 - PlamenTSV

2024-01-09T20:14:17Z

I believe this issue is valid, https://github.com/code-423n4/2023-12-revolutionprotocol-findings/issues/27 as it's dupe, as they cover a completely different attack concept that is easily economically achievable.

#6 - MarioPoneder

2024-01-11T16:43:19Z

Thank you for your comment!

I appreciate you raising awareness about this issue again. @rocketman-21 However, as it's basically an attack on the network and rather relating to future deployments, QA still seems most appropriate.

All the best!

#7 - c4-judge

2024-01-11T16:43:31Z

MarioPoneder marked the issue as grade-b

#8 - radeveth

2024-01-11T17:28:01Z

Hey, @MarioPoneder!

I strongly believe that this issue should not be considered an attack on the network. As explained in all reports related to this vulnerability (#590, #730, #27), a malicious actor can win an auction in a way that is unfavorable to the protocol by employing block stuffing.

Here is a similar issue that was considered a valid medium severity issue in a previous Code4rena contest: https://github.com/code-423n4/2023-05-venus-findings/issues/525

Therefore, this vulnerability, which has a significant impact, should undoubtedly be mitigated by the protocol. Some recommendations for mitigation include:

  • Making the bidding window dependent on the current bid amount of the auction to disincentivize block stuffing.
  • Ensuring that the reserve price is set sufficiently high at the beginning of the auction.
  • Significantly increasing the time buffer to make a block stuffing attack more costly.

Have a good one!

#9 - MarioPoneder

2024-01-11T19:10:52Z

Thank you for your comment and I appreciate the mitigation recommendations!

#10 - notbozho

2024-01-11T22:00:44Z

Hey, @MarioPoneder!

I believe that this issue (and his dups #730 , #27 ) should be classified as Medium Severity. The wardens above explain in detail why this vulnerability has a significant impact on the Revolution Protocol.

Cheers.

#11 - MarioPoneder

2024-01-11T22:58:18Z

  1. The timeBuffer is set unreasonably low in this example and can be configured by the owner/DAO even after deployment, therefore nothing to worry.
  2. The assumed lenght of block stuffing attacks is unreasonably high. The one provided in the example with 175 seconds is already extraordinary.
AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter