Best Practice to track EigenLayer points

Sentio
6 min readMar 1, 2024

Author: Ye, 0xbe1, ngfam. Reviewer: Anton, pooleparty. Special thanks to ngfam and 0xbe1 for contributing the idea and code.

Background: the rise of EigenLayer ecosystem and EigenLayer points

DeFi users have flocked to restaking protocol EigenLayer (EL) and liquid restaking protocols (LRTs) recently due to a rising trend around points.

EigenLayer enables users to restake Ether to secure Ethereum and other protocols. For doing this, restakers can expect to receive additional yields from other protocols, in addition to the Ether staking yield they already receive.

EigenLayer stakers receive restaked points as a measure of their contribution to the EigenLayer ecosystem. The points are measured by the time-integrated amount staked in units of ETH ⋅ hours. For instance, a user who stakes 1 stETH for 10 days should accrue 240 restaking points over this time period (1 ETH × 10 days × 24 hours/day = 240 ETH ⋅ hours). Points are expected to be converted to tokens sometime in the second quarter.

Challenge: tracking EL points for EL ecosystem projects is hard

When users stake Ether through a liquid restaking protocol, the liquid restaking protocol is responsible for calculating the exact points allocation to all users and giving the corresponding points back to the users.

Calculating this is HARD for protocols with a continuous point distribution stream.

Taking liquid restaking protocol Renzo as an example, the calculation looks simple if all users just hold ezETH. We just need to track all addresses holding ezETH and calculate the points accordingly. However, this is not how it actually works in Defi. There’s ezETH/ETH AMM pool for users to swap, where LPs provide liquidity for the AMM pools. If a user swap ezETH to ETH in the pool, the ezETH holding of all LPs in this AMM will get affected. Some might think, we could take a snapshot periodically for the calculation. Indeed, it simplifies the calculation; however, we think this is a big compromise as attribution fairness directly impacts the interest of users.

The point tracking becomes more complex when we consider Renzo needs to integrate ecosystem projects including Balancer, Curve, and Pendle. It is not easy for Renzo to understand all details of the Pendle AMM, and for both teams to align and sync point calculations using a standard approach.

Tracking this with Subgraph is very hard; on the other hand, Sentio provides an easier and more programmable way for developers to control what data gets collected.

Solution: an efficient and standardised way to track continuous EL point stream

Renzo, Pendle and Sentio has worked together to came up with an efficient and standardised approach, to help projects in EL ecosystem to track and allocate points.

  1. Triggered by relevant events, we shall do something to update everyone’s points
CurveStableSwapNGProcessor.bind({
address: ezETH_WETH_LPT_ADDRESS,
startBlock: POOL_START_BLOCK,
})
.onEventAddLiquidity(async (event, ctx) => {
//do something to update everyone's the points
})
.onEventRemoveLiquidity(async (event, ctx) => {
//do something to update everyone's the points
})
.onEventTokenExchange(async (event, ctx) => {
//do something to update everyone's the points
})
.onEventTransfer(async (event, ctx) => {
//do something to update everyone's the points
})

2. Inside each EventHandler, find relevant user(s), and update his(their) point(s).

Case 1: For AddLiquidityEvent, only one user needs to get updated.

.onEventAddLiquidity(async (event, ctx) => {
//get user address, null if not found
const accountAddress = event.args.provider
//find current account snapshot
const accountSnapshot: AccountSnapshot | null = await db.asyncFindOne({
_id: accountAddress,
})
//update this user's point
await processAccount(ctx, accountAddress, accountSnapshot)
})

where you have type AccountSnapshot that’s persisted in nedb.

// Persisted in nedb
// Have to store (lptBalance, lptSupply, poolEzETHBalance, poolWETHBalance) as string rather than bigint for nedb serde
type AccountSnapshot = {
_id: string;
epochMilli: number;
lptBalance: string;
lptSupply: string;
poolEzETHBalance: string;
poolWETHBalance: string;
}

Yes. Sentio allows persisted local storage that you can read and write customised data into database. You can start using it in processoer by 1 line of code.

// files stored under /data will be persisted by sentio and accessible by processor
const db = new AsyncNedb({ filename: "/data/accounts.db", autoload: true });

Case 2: After a swap event, more than one, in fact all LP users’ positions need to get updated.

.onEventTokenExchange(async (event, ctx) => {
//get all users
const accountSnapshots = await db.asyncFind<AccountSnapshot>({});
//update all users
for (const accountSnapshot of accountSnapshots) {
await processAccount(ctx, accountSnapshot._id, accountSnapshot);
}
})

3. Calculate and update points

Case 1: ezETH pool in Curve

Now we get to the implementation to calculate points and refresh them for users.

// accountSnapshot is null if snapshot hasn't been taken for this account yet
async function processAccount(
ctx: CurveStableSwapNGContext,
accountAddress: string,
accountSnapshot: AccountSnapshot | null
) {
//calcualte latest points in past period, and record
const elPoints = accountSnapshot
? calcPoints(ctx.timestamp.getTime(), accountSnapshot)
: new BigDecimal(0)
elPointCounter.add(ctx, elPoints, { account: accountAddress })

//use view function to get latest user info and record
const latestAccount = await getLatestAccountSnapshot(ctx, accountAddress)
await db.asyncUpdate({ _id: accountAddress }, latestAccount, {
upsert: true,
});

ctx.eventLogger.emit("point_update", {
lastUpdate: accountSnapshot?.epochMilli ?? 0,
elPoints,
...latestAccount
})
}


function calcPoints(
nowMilli: number,
accountSnapshot: AccountSnapshot
): BigDecimal {
// time check for async handler issue
if (nowMilli <= accountSnapshot.epochMilli) {
console.error(
"unexpected account snapshot from the future",
accountSnapshot
)
return new BigDecimal(0)
}

//update points using pool share
const deltaHour =
(nowMilli - accountSnapshot.epochMilli) / MILLISECOND_PER_HOUR
const { lptBalance, lptSupply, poolEzETHBalance, poolWETHBalance } =
accountSnapshot
const poolShare = BigInt(lptBalance)
.asBigDecimal()
.div(BigInt(lptSupply).asBigDecimal())
const accountEzETHBalance = poolShare.multipliedBy(
BigInt(poolEzETHBalance).scaleDown(TOKEN_DECIMALS)
)

const elPoints = accountEzETHBalance.multipliedBy(deltaHour);

return elPoints
}

Case 2: ezETH pool in Pendle

The Pendle team has also provided standard functions to handle the tracking of EL points as outlined below. This boosts your integration efficiency by an order of magnitude.

ERC20Processor.bind({
address: PENDLE_POOL_ADDRESSES.SY,
startBlock: PENDLE_POOL_ADDRESSES.START_BLOCK,
name: "Pendle Pool SY",
}).onEventTransfer(async(evt, ctx) => {
await handleSYTransfer(evt, ctx);
})


PendleYieldTokenProcessor.bind({
address: PENDLE_POOL_ADDRESSES.YT,
startBlock: PENDLE_POOL_ADDRESSES.START_BLOCK,
name: "Pendle Pool YT",
}).onEventTransfer(async(evt, ctx) => {
await handleYTTransfer(evt, ctx);
}).onEventRedeemInterest(async(evt, ctx) => {
await handleYTRedeemInterest(evt, ctx);
}).onEventNewInterestIndex(async(_, ctx) => {
const YTIndex = await ctx.contract.pyIndexStored();
const YTIndexPreviousBlock = await ctx.contract.pyIndexStored({blockTag: ctx.blockNumber - 1});
if (YTIndex == YTIndexPreviousBlock) return;
await processAllYTAccounts(ctx);
});

PendleMarketProcessor.bind({
address: PENDLE_POOL_ADDRESSES.LP,
startBlock: PENDLE_POOL_ADDRESSES.START_BLOCK,
name: "Pendle Pool LP",
}).onEventTransfer(async(evt, ctx) => {
await handleLPTransfer(evt, ctx);
}).onEventRedeemRewards(async(evt, ctx) => {
await handleMarketRedeemReward(evt, ctx);
}).onEventSwap(async(evt, ctx) => {
await handleMarketSwap(evt, ctx);
});

EQBBaseRewardProcessor.bind({
address: PENDLE_POOL_ADDRESSES.EQB_STAKING,
startBlock: PENDLE_POOL_ADDRESSES.START_BLOCK,
name: "Equilibria Base Reward",
}).onEventStaked(async(evt, ctx) => {
await processAllLPAccounts(ctx, [evt.args._user.toLowerCase()]);
}).onEventWithdrawn(async(evt, ctx) => {
await processAllLPAccounts(ctx, [evt.args._user.toLowerCase()]);
})

ERC20Processor.bind({
address: PENDLE_POOL_ADDRESSES.PENPIE_RECEIPT_TOKEN,
startBlock: PENDLE_POOL_ADDRESSES.START_BLOCK,
name: "Pendle Pie Receipt Token",
}).onEventTransfer(async(evt, ctx) => {
await processAllLPAccounts(ctx, [
evt.args.from.toLowerCase(),
evt.args.to.toLowerCase(),
]);
});

ERC20Processor.bind({
address: PENDLE_POOL_ADDRESSES.STAKEDAO_RECEIPT_TOKEN,
startBlock: PENDLE_POOL_ADDRESSES.START_BLOCK,
name: "Stakedao Receipt Token",
}).onEventTransfer(async(evt, ctx) => {
await processAllLPAccounts(ctx, [
evt.args.from.toLowerCase(),
evt.args.to.toLowerCase(),
]);
});

4. Reading data through API & charts

Sentio supports both time-series and relational DB. This makes it very easy to retrieve a user’s total points, which are ever-changing counter values through PromQL (instead of heavy computation at the query time), as well as complicated referral points attribution through SQL.

5. Other implementation stuff

As we need to account the cumulative values of a user’s point here. Remember to set sequential mode to true.

import { GLOBAL_CONFIG } from "@sentio/runtime";
GLOBAL_CONFIG.execution = {
sequential: true,
}

Next step: help more EL ecosystem projects to track their point streams with ease!

We believe this provides a straightforward method for tracking points across all EL ecosystem projects. By tracking the point streams, it ensures the interests of all users, thereby supporting the ongoing prosperity of the EL community.

Thanks to Pendle and Renzo. We have open-sourced all the core code for integration. If you are interested, talk to us!

We will extend this to other ecosystems that utilizes point system. Stay tune!

(P.S. we also expect an upgrade to nedb next week!)

A Little About Renzo

Renzo is a Liquid Restaking Token (LRT) and Strategy Manager for EigenLayer. It provides an interface to the EigenLayer ecosystem, securing Actively Validated Services (AVS) and offering a higher yield than ETH staking. The Renzo protocol abstracts all complexity from the end-user and enables easy collaboration between users and EigenLayer node operators.
Learn more: https://www.renzoprotocol.com

A Little About Pendle

Pendle is the largest yield swaps protocol for term rates and yield trading in DeFi. Pendle’s adoption as a DeFi primitive has been growing across protocols and institutions, allowing access to deterministic yields and to hedge and speculate on yields.

Learn more: https://pendle.finance/

A Little About Sentio

Sentio is an observability platform for Web3. Sentio generates metrics, logs, and traces from smart contracts data through our low code solution, which could be used for anaytics & monitoring, simulate/debug transactions, data export API and more. Sentio supports Ethereum, BSC, Polygon, Solana, Sui, Aptos and more chains. Sentio is built by veteran engineers from Google, Linkedin, Microsoft, and TikTok, and backed by top investors like Lightspeed Venture Partners, Hashkey Capital, and Canonical Crypto.

Visit Sentio at sentio.xyz. Follow us on Twitter for more updates.

--

--

Sentio

End-to-end observability platform to help you gain insights, secure assets and troubleshoot transactions for your decentralized applications.