Как я токены 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 байт. А значит нам надо убрать 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к было). Тут даже писать нечего, простейшая математика в гугл таблицах.
Что дальше?
А дальше, мой дорогой читатель, в следующих статьях, я расскажу, как писал контракт, как мы завозили все это на фронт и о той ошибке, которую я совершил. Спойлер - она не критичная, просто чуть больше мне самому головной боли.