July 5, 2023

ElasticSwap: ошибка в формуле приводит новых поставщиков ликвидности к потере средств

Доброй охоты! Когда изучаешь код DeFi проектов, неизбежно встречаешь математические формулы, разобраться в которых часто очень сложно. Хотим рассказать про такого рода уязвимость, обнаруженную при аудите проекта ElasticSwap на площадке Code4rena в январе 2022 года.

Обзор проекта ElasticSwap

ElasticSwap — это первый автоматизированный маркет-мейкер (AMM), специально созданный для поддержки ребейзинг-токенов. Особенностью таких токенов является механизм изменения их количества в обращении, т.е. эластичное предложение токенов на рынке. CoinMarketCap дает такое объяснение: "Ребейзинг-токены похожи на стейблкоины в том смысле, что и те и другие имеют целевую цену. Однако, в отличие от стейблкоинов, ребейзинг-токены имеют эластичное предложение, а значит циркулирующее предложение токенов на рынке корректируется в соответствии со спросом и предложением без изменения суммарной стоимости актива в кошельках пользователей."

Обычно, AMM-проекты отказываются от поддержки подобных токенов, т.к. приходится учитывать еще и изменение баланса в результате ребейзинга. Команда ElasticSwap решает эту проблему. Поставщики ликвидности теперь могут получать ожидаемые в результате ребейзинга токены, при этом обеспечивая ликвидность в пулах ElasticSwap.

Определения, которые могут потребоваться для понимания деталей уязвимости:

exchange — один экземпляр AMM, который представляет пару baseToken и quoteToken.

baseToken — произвольный токен ERC20, который может быть ребейзинг-токеном (с эластичным предложением). Например, sOHM, sKLIMA и т. д.

quoteToken — произвольный токен ERC20, который не является ребейзинг-токеном (имеет фиксированное предложение).

decay — результат дисбаланса токенов, возникающий сразу после того, как в baseToken происходит ребейз.

Детали уязвимости

Влияние

Выявленная проблема с формулой является причиной, по которой поставщики ликвидности, при депозите только в один из токенов пары, получают меньшее значение quoteToken, чем предполагается протоколом.

Доказательство концепции

Рассмотрим случай, когда ребейз для baseToken происходит вверх:

Согласно документации ElasticSwap и соответствующему коду, Gamma - это отношение количества акций (ΔRo), полученных при вызове addLiquidity(), к новому значению переменной totalSupply (общее количество выпущенных акций, равное Ro' = Ro + ΔRo):

ΔRo = (Ro/(1 - Gamma)) * Gamma
⟺
ΔRo * ( 1 - Gamma ) = Gamma * Ro
ΔRo - Gamma * ΔRo = Gamma * Ro
ΔRo = Gamma * Ro + Gamma * ΔRo
           ΔRo    
Gamma = ---------
         Ro + ΔRo 

В контракте реализовано:

Gamma = ΔY / Y' / 2 * ( ΔX / Alpha^ )

где:

ΔY - это количество quoteToken, добавленное новым поставщиком ликвидности в пул (см. L#277);

Y' - новое значение общего количества quoteToken (Y) после вызова addLiquidity(), т.е. Y' = Y + ΔY (см. L#272 и L#278);

ΔX - это ΔY * Omega (см. L#259-263 и L#279);

Alpha^ - это Alpha - X (см. L#234-235 и L#280);

Например:

Дано:

  • Начальное состояние: X = Alpha = 1, Y = Beta = 1, Omega = X/Y = 1
  • Затем происходит ребейз baseToken вверх: пусть Alpha становится 10
  • Тогда текущее состояние будет: Alpha = 10, X = 1, Y = Beta = 1,

Omega = 1

Затем новый поставщик ликвидности вызывает addLiquidity(), допустим, с 4 quoteToken:

            4           4 * Omega      16
Gamma = ------------ * ------------ = ----
         (1+4) * 2       10 - 1        90

После вызова addLiquidity():

  • baseToken у нового поставщика: 10 * 16 / 90 = 160 / 90 = 1.7777777777777777
  • quoteToken у нового поставщика: (1+4) * 16 / 90 = 80 / 90 = 0.8888888888888888
  • В выражении quoteToken, общее количество будет: 160 / 90 / Omega + 80 / 90 = 240 / 90 = 2.6666666666666665

В результате новый поставщик ликвидности терпит убытки в размере 4 - 240 / 90 = 1.3333333333333333, выраженные в quoteToken.

Теперь рассмотрим случай, когда ребейз для baseToken происходит вниз:

Согласно документации ElasticSwap и соответствующему коду, Gamma так же, как и в предыдущем случае, является отношением количества акций (ΔRo), полученных при вызове addLiquidity(), к новому значению переменной totalSupply (общее количество выпущенных акций, равное Ro' = Ro + ΔRo).

Реализация в контракте:

Gamma = ΔX / X / 2 * ( ΔXByQuoteTokenAmount / Beta^ )

где:

ΔX это количество baseToken, добавленное новым поставщиком ликвидности в пул (см. L#357);

X - это balanceOf baseToken (см. L#358);

ΔXByQuoteTokenAmount - это ΔX / Omega, величина ΔX, выраженная в quoteToken (см. L#318-322, L#329-333 и L#359);

Beta^ - это maxΔX / Omega, величина maxΔX, выраженная в quoteToken;

maxΔX = X - Alpha (см. L#304-305, L#318-322, L#341-342 и L#360);

Например:

Дано:

  • Начальное состояние: X = Alpha = 10, Y = Beta = 10, Omega = X/Y = 1
  • Затем происходит ребейз baseToken вниз: пусть Alpha становится 1
  • Тогда текущее состояние будет: Alpha = 1, X = 10, Y = Beta = 10,

Omega = 1

Затем новый поставщик ликвидности вызывает addLiquidity(), допустим, с 4 baseToken:

            4           4 / Omega        8
Gamma = ---------- * ---------------- = ----
         10 * 2      (10 - 1) / Omega    90

После вызова addLiquidity():

  • baseToken у нового поставщика: (1 + 4) * 8 / 90 = 40 / 90 = 0.4444444444444444
  • quoteToken у нового поставщика: 10 * 8 / 90 = 80 / 90 = 0.8888888888888888
  • В выражении quoteToken, общее количество будет: 40 / 90 + 80 / 90 * Omega = 120 / 90 = 1.3333333333333333 < 4

В результате новый поставщик ликвидности терпит убытки в размере 4 - 120 / 90 = 2.6666666666666665, выраженные в quoteToken.

Подведение итогов

Данная уязвимость имеет категорию High. Как видно, была проделана большая работа по изучению документации и кодовой базы для выявления такой уязвимости. Это позволило уберечь пользователей от потери средств. Предложенные аудитором исправления в формулах не были приняты командой проекта, поэтому мы не стали их приводить. Ознакомиться с шагами, проделанными разработчиками ElasticSwap для исправления данной ошибки можно здесь.