Crypto Development
November 9, 2024

Как я токены Eigen раздавал. История в 3х частях.

Namaste. Поговорим снова про web3 и блокчейн, и снова с уклоном в разработку. Недавно ребята из 0y.io получили жирный (нет) эйрдроп от EigenLayer, и поскольку мы уже годами сотрудничаем по части разработки - обратились ко мне, чтобы я помог этот дроп раздать их сообществу тем, кто делегировал им свои рестейкнутые эфирки.

Не буду тратить время на то, чтобы рассказать, что такое EigenLayer, чем он хорош и чем ужасен. Если интересно - тык сюда, сюда, или сюда (хабр). Важно вот что: это протокол, который запустился меньше года назад, и всему своему сообществу насыпал наград за участие. Насыпал, как обыкновенным пользователям (я лично получил 100 и 4 с копейками Eigen за 1 и 2 сезон соответственно), так и операторам, например 0y.

0y - ребята щедрые, и разделили всю награду на 2 части - делегаторам (пользователям, которые выбрали их в качестве оператора) и на инфру.

Итог: есть токены, и их надо раздать.

Часть первая: как мы токены считали.

Еще на том этапе, когда не было понятно, сколько токенов насыплют - было понимание, что это произойдет. А значит надо готовиться. Мы выбрали вот такую формулу: points = nETH * 10000 * days. Иными словами 10,000 поинтов за 1 делегированный эфир в день.

Какие вводные еще есть? Адрес контракта EigenLayer, адрес оператора 0y, адрес пользователя, текущая дата и какая-то начальная дата (не надо же нам искать с генезис блока эфира). Да начнется ресерч!

Сначала идем в эзерскан и смотрим, что там с контрактом, находим в нем транзу, которой мы знаем, что кто-то делегировал оператору (я просто сам заделегировал 0.02 эфира (через лайдо)). Идем в логи и смотрим: опа, тут есть событие OperatorSharesIncreased. Что-то мне подсказывает, что в транзакции на отмену делегации будет что-то подобное. И да, вот и OperatorSharesDecreased. Кстати, там же в логах видим адрес нашего оператора 0y, это плюс.

Логи транзы на анстейк из делегатора.

Окей, забираем hex этих 2х событий и получаем 2 константы, которые будем искать (ну и еще 2 объявим с именами экшенов):

export const OperatorSharesDecreased =  "0x6909600037b75d7b4733aedd815442b5ec018a827751c832aaff64eba5d6d2dd";
export const OperatorSharesDecreasedAction = "OperatorSharesDecreased";
export const OperatorSharesIncreased =  "0x1ec042c965e2edd7107b51188ee0f383e22e76179041ab3a9d18ff151405166c";
export const OperatorSharesIncreasedAction = "OperatorSharesIncreased";

Ок, го некст. Идем на алхимию, создаем ключ, подключаем в код провайдера.

const provider = new ethers.providers.JsonRpcProvider(
  `https://eth-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_PRIVATE_KEY}`
);

Как же хорошо, что это эфир и нет проблем с архивными нодами, поэтому прямо из алкхимии можно вытащить всю историю и все логи. 5 минут гугла и находим, как получить исторические логи, еще 10 минут и код готов:

async function getLogs(
  provider: ethers.providers.JsonRpcProvider, // провайдер
  delegationContract: string, // контракт управляющий делегациями EigenLayer
  operator: string, // оператор, в нашей ситуации 0y
  operation: string, // действие, которые мы ищем в логе
  fromBlock?: number, // с какого блока ищем
  toBlock?: number // до какого блока ищем
) {
  return await provider.getLogs({
    fromBlock: fromBlock ? toBigNumber(fromBlock).toHexString() : undefined,
    toBlock: toBlock ? toBigNumber(toBlock).toHexString() : undefined,
    address: delegationContract || EigenLayerDelegationContract,
    topics: [operation, operator],
  });
}

Что тут происходит? Если непонятно по комментам, то у меня есть: что искать (оператор и действие), где искать (в контракте EigenLayer) и когда искать (между fromBlock и toBlock).

Ну и сам вызов:

const increasingLogs = await getLogs(
  provider,
  contract,
  _operator,
  OperatorSharesIncreased,
  fromBlock,
  toBlock
);

И что же нам вернется? ethers.provider.Log[] - говорит мне IDE. Массив какого-то говна лога, ок. Идем в код, идем в доку, и видим:

export interface Log {
  blockNumber: number;
  blockHash: string;
  transactionIndex: number;
  removed: boolean;
  address: string;
  data: string;
  topics: Array<string>;
  transactionHash: string;
  logIndex: number;
}

Оооок... Что-то мне подсказывает, что меня интересует data и topics. Смотрим глазами в консоль:

Смотрим в output, смотрим еще раз в etherscan, смотрим output, смотрим etherscan, смотрим output, смотрим etherscan... Ну вы поняли. Пишем парсер лога.

Сначала мы распарсим дату, получим набор строк. Смотрим еще раз в эзерскан и понимаем, что нулевой элемент - это наш стейкер. Записали. Оператора забираем из топиков. То, что здесь называется стратегией - тоже берем из даты, нам это еще понадобится, но скажу честно, на начальном этапе я не понимал зачем и что это вообще такое. Amount - тоже берем из даты.

function parseLogs(
  logs: ethers.providers.Log[],
  action: OperatorReStakerAction["action"]
): Map<string, OperatorReStakerAction[]> {
  const stakers: Map<string, OperatorReStakerAction[]> = new Map();

  for (const log of logs) {
    const data = parseData(log.data); // будет ниже, там особенно интересно

    const staker = parseAddress(data[0]).toLowerCase();

    const stakerData = stakers.get(staker) || [];
    const awsOperator = parseAddress(log.topics[1]);
    const strategy = parseAddress(data[1]);

    stakers.set(staker, [
      ...stakerData,
      {
        amount: toBigNumber(`0x${data[2]}`),
        block: toBigNumber(log.blockNumber),
        action,
        strategy,
        awsOperator,
      },
    ]);
  }

  return stakers;
}

Если интересует, что за parseAddress - то все просто.

В Ethereum адреса имеют длину 20 байт (160 бит). Это определено протоколом Ethereum, где каждый адрес представляет собой 20-байтное значение, используемое для идентификации аккаунтов, контрактов и других сущностей в сети.

Формат отображения адреса

Адрес в Ethereum обычно отображается в шестнадцатеричной (hex) системе и начинается с префикса 0x. Поскольку каждый байт может быть представлен двумя шестнадцатеричными символами (от 00 до FF), полный адрес занимает 40 символов в шестнадцатеричном представлении:

  • 20 байт = 20 * 2 = 40 hex символов

Длинна адреса - 20 байт. А значит нам надо убрать 24 лидирующих нуля и добавить 0x в начало, чтобы получить адрес.

function parseAddress(data: string) {
  const [, addr] = data.split("0".repeat(24));
  return `0x${addr}`;
}

Так, давай разберемся с парсингом даты. Что мы видим?

0x000000000000000000000000269df236ae8bd066e9de7670a7cbfd8cbafd11c200000000000000000000000013760f50a9d7377e4f20cb8cf9e4c26586c658ff0000000000000000000000000000000000000000000000002470a7f61539b90a

Длинная, как Мисисипи строка, с лидирующим 0x. Если интересно - вот ссылка на спеку, но что важно знать - там может быть все, что угодно, что начинается с 0x. В нашей ситуации - несколько групп по 32 байта (64 символа) в каждой. Погнали писать парсер:

function parseData(d: string): string[] {
  let str = d; // убираем лидирующий 0x он нам тут наш не нужон
  if (d.startsWith("0x")) {
    str = removeLeading0x(d);
  }
  
  const arr = [...str];
  const result: string[] = [];
  
  if (arr.length === 0) {
    return [];
  } else if (arr.length === 1) { // вспомнить бы зачем, хотя припоминаю когда в data встречал 0x0, но тут явно не то случай. Но пусть будет.
    return [str];
  }
  
  let line = "";
  
  arr.forEach((l) => {
    line += l;
    // каждые 32 байта - новая строка
    // 2 символа - 1 байт
    // нужно 64 символа
    if (line.length === 64) {
      result.push(line);
      word = "";
    }
  });

  if (line.length > 0) {
    result.push(line);
  }
  
  return result;
}

Все, что надо мы написали, осталось все это вызвать:

export async function getOperatorDelegatorsHistory(
  provider: ethers.providers.JsonRpcProvider,
  {
    fromBlock,
    toBlock,
    operator,
    delegationContract,
  }: {
    fromBlock?: number;
    toBlock?: number;
    operator: string;
    delegationContract?: string;
  }
): Promise<Map<string, OperatorReStakerAction[]>> {
  const _operator = `0x${"0".repeat(24)}${removeLeading0x(operator)}`;

  const stakers: Map<string, OperatorReStakerAction[]> = new Map();

  const contract = delegationContract || EigenLayerDelegationContract;
  const increasingLogs = await getLogs(
    provider,
    contract,
    _operator,
    OperatorSharesIncreased,
    fromBlock,
    toBlock
  );

  const decreasingLogs = await getLogs(
    provider,
    contract,
    _operator,
    OperatorSharesDecreased,
    fromBlock,
    toBlock
  );

  const increasingActions = parseLogs(
    increasingLogs,
    OperatorSharesIncreasedAction
  );

  const decreasingActions = parseLogs(
    decreasingLogs,
    OperatorSharesDecreasedAction
  );

  const stakersArray = [
    ...new Set([...increasingActions.keys(), ...decreasingActions.keys()]),
  ];

  stakersArray.forEach((staker) => {
    const inc = increasingActions.get(staker) || [];
    const dec = decreasingActions.get(staker) || [];

    stakers.set(
      staker,
      [...inc, ...dec].sort((a, b) => (a.block > b.block ? 1 : -1))
    );
  });

  return stakers;
}

Так как я писал все в TDD парадигме, и сам заносил в протокол - то у меня не было проблем с тем, чтобы проверить, что все, что тут есть - работает так, как надо.

describe("getOperatorDelegatorsHistory", () => {
  it("check", async () => {
    const provider = new ethers.providers.JsonRpcProvider(
      `https://eth-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_PRIVATE_KEY}`
    );

    const data = await getOperatorDelegatorsHistory(provider, {
      fromBlock: 19576120,
      operator: "0xd172a86a0f250aec23ee19c759a8e73621fe3c10",
    });

    const realDelegator = "0x3877fbDe425d21f29F4cB3e739Cf75CDECf8EdCE";
    
    // строки сравнивать проще, так что конвертим реальные данные
    const realDelegations: string[] = [
      ["19388606404441598", 19676121, OperatorSharesIncreasedAction],
      ["4847151601110399", 19677373, OperatorSharesIncreasedAction],
      ["969344385657699", 19689181, OperatorSharesDecreasedAction],
    ].map(([amount, block, action]) => `${amount}_${block}_${action}`);

    expect(data.has(realDelegator));
    
    const delegations: string[] = (
      data.get(realDelegator.toLocaleLowerCase()) || []
    ).map(
      (delegation) =>
        // конвертим данные из бч в строку
        `${delegation.amount.toString()}_${delegation.block.toNumber()}_${
          delegation.action
        }`
    );

    for (const realDelegation of realDelegations) {
      expect(delegations.includes(realDelegation)).toBeTruthy();
    }
  });
});

Либа

Так как кода много, надо бы сделать его переиспользуемым. В итоге получилась либа, которую я просто подгрузил в нужный момент в проект. Да, 0 скачиваний в неделю - это успех, но пофиг, это для себя в первую очередь. Исходники тут.

Поинты

Ок! Мы получили мапу стейкеров с их действиями в протоколе, все, что осталось - это посчитать, сколько поинтов получил каждый. Для начала, нужно вычислить дату блока, тут все просто provider.getBlock(block) вернет нам инфу по блоку, в том числе и timestamp. Вполне логично, что грузить каждый раз это нам не нужно, так что можно сохранить полученные данные в базу (я просто писал в файл).

function getBlockTimeStampBase(provider: ethers.providers.JsonRpcProvider, base: Map<number, number>) {
  const stack: Map<number, number> = new Map(base);

  return async (block) => {
    if (stack.has(block)) {
      return stack.get(block);
    }

    const timestamp = (await provider.getBlock(block)).timestamp;
    stack.set(block, timestamp);

    return timestamp;
  };
}

const getBlockTimeStamp = getBlockTimeStampBase(provider, timestamps);

const timestamp = await getBlockTimeStamp(stake.block.toNumber());

Далее идем по массиву действий и проводим вычисления: определяем дату, определяем дату следующего события, потому что считать нам надо только в рамках одного события, ведь дальше стейк изменится. Если нет следующей даты - берем дату окончания кампании (мы зафинализировали на 20871841 блоке). Изи катка.

_accountStakes.forEach((stake, index) => {
      if (!strategies.includes(stake.strategy.toLowerCase())) {
        return;
      }

      // https://etherscan.io/block/20871841
      const finalBlockTimestamp = 1727800151000;
      const nextDate = _accountStakes[index + 1] ? +`${_accountStakes[index + 1].timestamp}000` : finalBlockTimestamp;
      const distance = nextDate - +`${stake.timestamp}000`;

      const days = distance / 1000 / 60 / 60 / 24;

      if (stake.action === OperatorSharesDecreased) {
        stakeValue -= BigInt(stake.amount.toString())
      } else {
        stakeValue += BigInt(stake.amount.toString())
      }

      const value = BigInt(days.toFixed(0)) * stakeValue;
      
      rewards.push(value * BigInt(10_000))
});

return ethers.utils.formatEther(rewards.reduce((acc, value) => acc += value, BigInt(0)))

А, ну да, так как мы работаем с эфиром, то у нас тут числа без плавающей точки, но с 18 нулями, а мне бы в простом виде, так что дергаем ethers.utils.formatEther. И еще важный момент: формат таймстемпа, чтоб в js работать с ним - добавляем 3 нуля в конец.

Что-то пошло не так

Помнишь, я писал про то что выцепляю какую-то "стратегию" из лога. Дак вот, когда EigenLayer выдали первый дроп, его можно (и нужно) было запихнуть в стейкинг, чтоб еще немного подзаработать. Блок был примерно на 3 месяца, я запихнул свои 100 EIGEN, получил примерно 4.5. APR 18% - найс. Дак вот, стратегия - это как раз то, какой рестейкнутый токен ты запихиваешь в оператора: stETH, wBETH, ankrETH... или Eigen. И вот тут то и случилась жопа, потому что под каждый токен деплоился отдельный управляющий контракт, и их пришлось глазами выискивать. Заняло час рутинной работы, но все получилось.

const __data = `1. ETH
0x0000000000000000000000000000000000000000-0xbeac0eeeeeeeeeeeeeeeeeeeeeeeeeeeeeebeac0

2. WETH
0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2

3. rETH
0xae78736cd615f374d3085123a210448e74fc6393-0x1bee69b7dfffa4e2d53c2a2df135c388ad25dcd2

4. stETH
0xae7ab96520de3a18e5e111b5eaab095312d7fe84-0x93c4b944d05dfe6df7645a86cd2206016c51564d

5. cbETH
0xbe9895146f7af43049ca1c1ae358b0541ea49704-0x54945180db7943c0ed0fee7edab2bd24620256bc

6. osETH
0xf1c9acdc66974dfb6decb12aa385b9cd01190e38-0x57ba429517c3473b6d34ca9acd56c0e735b94c02

7. swETH
0xf951e335afb289353dc249e82926178eac7ded78-0x0fe4f44bee93503346a3ac9ee5a26b130a5796d6

8. oETH
0x856c4efb76c1d1ae02e20ceb03a2a6a08b0b8dc3

9. wBETH
0xa2e3356610840701bdf5611a53974510ae27e2e1-0x7ca911e83dabf90c90dd3de5411a10f1a6112184

10. ankrETH
0xe95a203b1a91a908f9b9ce46459d101078c2c3cb-0x13760f50a9d7377e4f20cb8cf9e4c26586c658ff

11. ETHx
0xa35b1b31ce002fbf2058d22f30f95d405200a15b-0x9d7ed45ee2e8fc5482fa2428f15c971e6369011d

12. sfrxETH
0xac3e018457b222d93114458476f3e3416abbe38f

13. lsETH
0x8c1bed5b9a0928467c9b1341da1d7bd5e10b6549-0xae60d8180437b5c34bb956822ac2710972584473

14. mETH
0xd5f7838f5c461feff7fe49ea5ebaf7728bb0adfa-0x298afb19a105d59e74658c4c334ff360bade6dd2`;

const strategies = __data
  .split("\n")
  .filter(l => l.startsWith("0x"))
  .map(s => s.toLowerCase().split("-")[1])
  .filter(Boolean);

Поиск облегчило то, что люди заносили в 0y, так что список не полный. Ребята сказали игнорить "не эфир", так что не пришлось возиться с определением курса Eigen на момент занесения. По этому фильтруем по стратегии (исключаем Eigen) и двигаемся дальше.

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

Это поинты, а сколько токенов?

Когда эйген отсыпали операторам, 0y решили распределить половину наград стейкерам, у которых баланс поинтов был больше 1М, и между ними - пропорционально, относительно размера стейка (я пролетел, у меня 38к было). Тут даже писать нечего, простейшая математика в гугл таблицах.

Что дальше?

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