diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a6fda8..7693a93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/package.json b/package.json index 42921a1..9fa0bda 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/util/get-epoch-status.spec.ts b/src/util/get-epoch-status.spec.ts new file mode 100644 index 0000000..852c149 --- /dev/null +++ b/src/util/get-epoch-status.spec.ts @@ -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) + }) +}) diff --git a/src/util/get-epoch-status.ts b/src/util/get-epoch-status.ts new file mode 100644 index 0000000..546a317 --- /dev/null +++ b/src/util/get-epoch-status.ts @@ -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 - 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 { + 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, + } +} diff --git a/src/util/index.ts b/src/util/index.ts index 023ad7e..eb5d757 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -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'