Skip to content

Commit

Permalink
feat: add method to retrieve parsed EpochRewards data
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexStefan committed Dec 18, 2024
1 parent c490237 commit 4c73d87
Show file tree
Hide file tree
Showing 2 changed files with 113 additions and 13 deletions.
24 changes: 18 additions & 6 deletions src/util/get-epoch-status.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,37 +14,49 @@ const mockEpochInfo = (
describe('getEpochStatus', () => {
it('should return REWARDS_DISTRIBUTION when slotsPassed is less than or equal to slotsToPayRewards', () => {
const epochInfo = mockEpochInfo(307152300, 711, 432000)
const status = getEpochStatus(epochInfo.slotIndex)
const status = getEpochStatus({ absolutSlot: epochInfo.absoluteSlot })
expect(status).toBe(EpochStatus.REWARDS_DISTRIBUTION)
})

it('should return REWARDS_DISTRIBUTION when epcohInfo is passed with active state no matter the rest of the info', () => {
const epochInfo = mockEpochInfo(307193449, 711, 432000)
const status = getEpochStatus({
absolutSlot: epochInfo.absoluteSlot,
epochRewardsInfo: { active: true },
})
expect(status).toBe(EpochStatus.REWARDS_DISTRIBUTION)
})

it('should return PRE_EPOCH when timeRemaining is less than 300 seconds', () => {
const epochInfo = mockEpochInfo(307583750, 711, 432000)
const status = getEpochStatus(epochInfo.slotIndex)
const status = getEpochStatus({ absolutSlot: epochInfo.absoluteSlot })
expect(status).toBe(EpochStatus.PRE_EPOCH)
})

it('should return OPERABLE when neither REWARDS_DISTRIBUTION nor PRE_EPOCH conditions are met', () => {
const epochInfo = mockEpochInfo(307193449, 711, 432000)
const status = getEpochStatus(epochInfo.slotIndex)
const status = getEpochStatus({ absolutSlot: epochInfo.absoluteSlot })
expect(status).toBe(EpochStatus.OPERABLE)
})

it('should correctly return REWARDS_DISTRIBUTION when slotsPassed equals slotsToPayRewards', () => {
const epochInfo = mockEpochInfo(307152367, 711, 432000)
const status = getEpochStatus(epochInfo.slotIndex)
const status = getEpochStatus({ absolutSlot: epochInfo.absoluteSlot })
expect(status).toBe(EpochStatus.REWARDS_DISTRIBUTION)
})

it('should correctly return PRE_EPOCH when timeRemaining is exactly 500 slots before epoch ends', () => {
const epochInfo = mockEpochInfo(307583500, 711, 432000)
const status = getEpochStatus(epochInfo.slotIndex)
const status = getEpochStatus({ absolutSlot: epochInfo.absoluteSlot })
expect(status).toBe(EpochStatus.PRE_EPOCH)
})

it('should handle custom number of stake accounts correctly', () => {
const epochInfo = mockEpochInfo(307152245, 711, 432000)
const status = getEpochStatus(epochInfo.slotIndex, 1000000)
const status = getEpochStatus({
absolutSlot: epochInfo.absoluteSlot,
noOfStakeAccounts: 1000000,
})
expect(status).toBe(EpochStatus.REWARDS_DISTRIBUTION)
})
})
102 changes: 95 additions & 7 deletions src/util/get-epoch-status.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,111 @@
import { Connection, PublicKey } from '@solana/web3.js'
import BN from 'bn.js'

/**
* EpochStatus represents the current state of the epoch.
* - OPERABLE: Normal state where interactions with stake accounts are possible.
* - PRE_EPOCH: The epoch is nearing its end; operations may not complete in time.
* - REWARDS_DISTRIBUTION: Rewards are actively being distributed to stake accounts.
*/
export enum EpochStatus {
OPERABLE,
PRE_EPOCH,
REWARDS_DISTRIBUTION,
}

export function getEpochStatus(slotIndex: number, noOfStakeAccounts = 1500000) {
export type EpochRewardsInfo = {
distributionStartingBlockHeight?: BN
numPartitions?: BN
parentBlockhash?: string
totalPoints?: BN
totalRewards?: BN
distributedRewards?: BN
active?: boolean
}

/**
* Determines the current status of the epoch based on slot information and rewards data.
*
* @param absolutSlot - The current absolute slot number.
* @param noOfStakeAccounts - Total number of stake accounts (default is 1,500,000).
* @param skipRate - The rate of skipped slots, between 0 and 1 (default is 0.1 or 10%).
* @param warningNoSlotsBeforeEpochEnd - Number of slots before epoch end to trigger PRE_EPOCH status.
* @param epochRewardsInfo - Optional object containing the current epoch rewards data.
*
* @returns EpochStatus - The current status of the epoch:
* - REWARDS_DISTRIBUTION: Rewards are being distributed.
* - PRE_EPOCH: Epoch is about to end.
* - OPERABLE: Epoch is in a normal operating state.
*/
export function getEpochStatus({
absolutSlot,
noOfStakeAccounts = 1500000,
skipRate = 0.1,
warningNoSlotsBeforeEpochEnd = 500,
epochRewardsInfo,
}: {
absolutSlot: number
noOfStakeAccounts?: number
skipRate?: number
warningNoSlotsBeforeEpochEnd?: number
epochRewardsInfo?: EpochRewardsInfo
}) {
// It takes around 4096 stake accounts per block to receive rewards.
// Given the total number of stake accounts on chain we should compute
// the number of blocks needed to distribute all the staking rewards
const slotsToPayRewards = Math.ceil(noOfStakeAccounts / 4096)
// An arbitrary amount of blocks to signal that the epoch is about to end
// and transactions interacting with stake accounts might not make it within current epoch
const slotsPreEpoch = 432000 - 500
// the number of blocks needed to distribute all the staking rewards taking into account skipRate
const slotsToPayRewards = Math.ceil(noOfStakeAccounts / 4096 / (1 - skipRate))
// Amount of blocks to signal that the epoch is about to end and
// transactions interacting with stake accounts might not make it within current epoch
const slotsPreEpoch = 432000 - warningNoSlotsBeforeEpochEnd
const slotIndex = absolutSlot % 432000

if (slotIndex <= slotsToPayRewards) {
if (epochRewardsInfo?.active || slotIndex <= slotsToPayRewards) {
return EpochStatus.REWARDS_DISTRIBUTION
} else if (slotIndex >= slotsPreEpoch) {
return EpochStatus.PRE_EPOCH
}

return EpochStatus.OPERABLE
}

/**
* Fetches the current rewards distribution state from the SysvarEpochRewards account.
*
* @param connection - A Solana Connection object used to query on-chain data.
*
* @returns Promise<EpochRewardsInfo> - Returns an object containing rewards information,
* such as total rewards, distributed rewards, and the active state that reflects if reward distribution is still running.
* @throws Error - If the SysvarEpochRewards account data cannot be fetched.
*/
export async function getParsedEpochRewards(
connection: Connection
): Promise<EpochRewardsInfo> {
const accountInfo = await connection.getAccountInfo(
new PublicKey('SysvarEpochRewards1111111111111111111111111')
)

if (!accountInfo) {
throw new Error('SysvarEpochRewards account info could not be fetched.')
}

const distributionStartingBlockHeight = new BN(
accountInfo.data.subarray(0, 8),
'le'
)
const numPartitions = new BN(accountInfo.data.subarray(8, 16), 'le')
const parentBlockhash = accountInfo.data.subarray(16, 48).toString('hex')
const totalPoints = new BN(accountInfo.data.subarray(48, 64), 'le')
const totalRewards = new BN(accountInfo.data.subarray(64, 72), 'le')
const distributedRewards = new BN(accountInfo.data.subarray(72, 80), 'le')
const active = Boolean(accountInfo.data.readUInt8(80))

return {
distributionStartingBlockHeight,
numPartitions,
parentBlockhash,
totalPoints,
totalRewards,
distributedRewards,
active,
}
}

0 comments on commit 4c73d87

Please sign in to comment.