Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: create method getEpochStatus #74

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## v5.0.16

### Feat:

- Add util method to fetch epoch status from stake rewards distribution perspective based on current slot

## v5.0.15

### Feat:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@marinade.finance/marinade-ts-sdk",
"version": "5.0.15",
"version": "5.0.16",
"description": "Marinade SDK for Typescript",
"main": "dist/src/index.js",
"repository": {
Expand Down
62 changes: 62 additions & 0 deletions src/util/get-epoch-status.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { EpochStatus, getEpochStatus } from './get-epoch-status'

const mockEpochInfo = (
absoluteSlot: number,
epoch: number,
slotsInEpoch: number
) => ({
absoluteSlot,
epoch,
slotsInEpoch,
slotIndex: absoluteSlot % slotsInEpoch,
})

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({ 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({ 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({ 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({ 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({ 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({
absolutSlot: epochInfo.absoluteSlot,
noOfStakeAccounts: 1000000,
})
expect(status).toBe(EpochStatus.REWARDS_DISTRIBUTION)
})
})
111 changes: 111 additions & 0 deletions src/util/get-epoch-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +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 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 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 (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,
}
}
1 change: 1 addition & 0 deletions src/util/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './anchor'
export * from './anchor.types'
export * from './array'
export * from './get-epoch-status'
export * from './conversion'
export * from './state-helpers'
export * from './ticket-date-info'
Loading