diff --git a/packages/staking/index.ts b/packages/staking/index.ts index cb9ec94..65c971b 100644 --- a/packages/staking/index.ts +++ b/packages/staking/index.ts @@ -1,4 +1,6 @@ export { default as SolanaStakingClient } from "./solana/client.js"; export * from "./solana/utils.js"; export * from "./solana/types.js"; +export * from "./solana/lib/derive-accounts.js"; +export * from "./solana/lib/rewards.js"; export * as constants from "./solana/constants.js"; diff --git a/packages/staking/solana/client.ts b/packages/staking/solana/client.ts index f8f1e01..319a1b9 100644 --- a/packages/staking/solana/client.ts +++ b/packages/staking/solana/client.ts @@ -62,7 +62,7 @@ import { deriveStakeEntryPDA, deriveStakeMintPDA, deriveStakePoolPDA, -} from "./utils.js"; +} from "./lib/derive-accounts.js"; interface Programs { stakePoolProgram: Program; diff --git a/packages/staking/solana/lib/derive-accounts.ts b/packages/staking/solana/lib/derive-accounts.ts new file mode 100644 index 0000000..4d90722 --- /dev/null +++ b/packages/staking/solana/lib/derive-accounts.ts @@ -0,0 +1,78 @@ +import { PublicKey } from "@solana/web3.js"; +// eslint-disable-next-line no-restricted-imports +import BN from "bn.js"; + +import { + STAKE_POOL_PREFIX, + STAKE_VAULT_PREFIX, + STAKE_MINT_PREFIX, + STAKE_ENTRY_PREFIX, + REWARD_POOL_PREFIX, + REWARD_VAULT_PREFIX, + REWARD_ENTRY_PREFIX, + CONFIG_PREFIX, + FEE_VALUE_PREFIX, +} from "../constants.js"; + +export const deriveStakePoolPDA = ( + programId: PublicKey, + mint: PublicKey, + authority: PublicKey, + nonce: number, +): PublicKey => { + return PublicKey.findProgramAddressSync( + [STAKE_POOL_PREFIX, mint.toBuffer(), authority.toBuffer(), new BN(nonce).toArrayLike(Buffer, "le", 1)], + programId, + )[0]; +}; + +export const deriveStakeVaultPDA = (programId: PublicKey, stakePool: PublicKey): PublicKey => { + return PublicKey.findProgramAddressSync([STAKE_VAULT_PREFIX, stakePool.toBuffer()], programId)[0]; +}; + +export const deriveStakeMintPDA = (programId: PublicKey, stakePool: PublicKey): PublicKey => { + return PublicKey.findProgramAddressSync([STAKE_MINT_PREFIX, stakePool.toBuffer()], programId)[0]; +}; + +export const deriveStakeEntryPDA = ( + programId: PublicKey, + stakePool: PublicKey, + authority: PublicKey, + nonce: number, +): PublicKey => { + return PublicKey.findProgramAddressSync( + [STAKE_ENTRY_PREFIX, stakePool.toBuffer(), authority.toBuffer(), new BN(nonce).toArrayLike(Buffer, "le", 4)], + programId, + )[0]; +}; + +export const deriveRewardPoolPDA = ( + programId: PublicKey, + stakePool: PublicKey, + mint: PublicKey, + nonce: number, +): PublicKey => { + return PublicKey.findProgramAddressSync( + [REWARD_POOL_PREFIX, stakePool.toBuffer(), mint.toBuffer(), new BN(nonce).toArrayLike(Buffer, "le", 1)], + programId, + )[0]; +}; + +export const deriveRewardVaultPDA = (programId: PublicKey, rewardPool: PublicKey): PublicKey => { + return PublicKey.findProgramAddressSync([REWARD_VAULT_PREFIX, rewardPool.toBuffer()], programId)[0]; +}; + +export const deriveRewardEntryPDA = (programId: PublicKey, rewardPool: PublicKey, stakeEntry: PublicKey): PublicKey => { + return PublicKey.findProgramAddressSync( + [REWARD_ENTRY_PREFIX, rewardPool.toBuffer(), stakeEntry.toBuffer()], + programId, + )[0]; +}; + +export const deriveConfigPDA = (programId: PublicKey): PublicKey => { + return PublicKey.findProgramAddressSync([CONFIG_PREFIX], programId)[0]; +}; + +export const deriveFeeValuePDA = (programId: PublicKey, target: PublicKey): PublicKey => { + return PublicKey.findProgramAddressSync([FEE_VALUE_PREFIX, target.toBuffer()], programId)[0]; +}; diff --git a/packages/staking/solana/lib/rewards.ts b/packages/staking/solana/lib/rewards.ts new file mode 100644 index 0000000..3b75865 --- /dev/null +++ b/packages/staking/solana/lib/rewards.ts @@ -0,0 +1,209 @@ +import { ProgramAccount } from "@coral-xyz/anchor"; +import { PublicKey } from "@solana/web3.js"; +// eslint-disable-next-line no-restricted-imports +import BN from "bn.js"; + +import { RewardEntry, StakeEntry, RewardPool } from "../types.js"; +import { SCALE_PRECISION_FACTOR_BN } from "../constants.js"; + +export const REWARD_AMOUNT_PRECISION_FACTOR = new BN("1000000000"); + +export class RewardEntryAccumulator implements RewardEntry { + lastAccountedTs: BN; + + claimedAmount: BN; + + accountedAmount: BN; + + rewardPool: PublicKey; + + stakeEntry: PublicKey; + + createdTs: BN; + + lastRewardAmount: BN; + + lastRewardPeriod: BN; + + buffer: number[]; + + constructor(public delegate: RewardEntry) { + this.lastAccountedTs = delegate.lastAccountedTs; + this.claimedAmount = delegate.claimedAmount; + this.accountedAmount = delegate.accountedAmount; + this.rewardPool = delegate.rewardPool; + this.stakeEntry = delegate.stakeEntry; + this.createdTs = delegate.createdTs; + this.buffer = delegate.buffer; + this.lastRewardAmount = delegate.lastRewardAmount; + this.lastRewardPeriod = delegate.lastRewardPeriod; + } + + // Calculate accountable amount by calculating how many seconds have passed since last claim/stake time + getAccountableAmount( + stakedTs: BN, + accountableTs: BN, + effectiveStakedAmount: BN, + rewardAmount: BN, + rewardPeriod: BN, + ): BN { + const lastAccountedTs = this.lastAccountedTs.gt(new BN(0)) ? this.lastAccountedTs : stakedTs; + const secondsPassed = accountableTs.sub(lastAccountedTs); + + if (secondsPassed.lt(rewardPeriod)) { + return new BN(0); + } + + const periodsPassed = secondsPassed.div(rewardPeriod); + + const claimablePerEffectiveStake = periodsPassed.mul(rewardAmount); + + const accountableAmount = claimablePerEffectiveStake.mul(effectiveStakedAmount).div(SCALE_PRECISION_FACTOR_BN); + + return accountableAmount; + } + + // Calculates claimable amount from accountable amount. + getClaimableAmount(): BN { + const claimedAmount = this.claimedAmount.mul(REWARD_AMOUNT_PRECISION_FACTOR); + const nonClaimedAmount = this.accountedAmount.sub(claimedAmount); + const claimableAmount = nonClaimedAmount.div(REWARD_AMOUNT_PRECISION_FACTOR); + + return claimableAmount; + } + + // Get the time of the last unlock + getLastAccountedTs(stakedTs: BN, claimableTs: BN, rewardPeriod: BN): BN { + const lastAccountedTs = this.lastAccountedTs.gtn(0) ? this.lastAccountedTs : stakedTs; + const totalSecondsPassed = claimableTs.sub(lastAccountedTs); + const periodsPassed = totalSecondsPassed.div(rewardPeriod); + const periodsToSeconds = periodsPassed.mul(rewardPeriod); + const currClaimTs = lastAccountedTs.add(periodsToSeconds); + + return currClaimTs; + } + + // Adds accounted amount + addAccountedAmount(accountedAmount: BN): void { + this.accountedAmount = this.accountedAmount.add(accountedAmount); + } + + // Adds claimed amount + addClaimedAmount(claimedAmount: BN): void { + this.claimedAmount = this.claimedAmount.add(claimedAmount); + } +} + +const createDefaultRewardEntry = ( + stakeEntry: ProgramAccount, + rewardPool: ProgramAccount, +): RewardEntry => { + return { + stakeEntry: new PublicKey(stakeEntry.publicKey), + rewardPool: new PublicKey(rewardPool.publicKey), + createdTs: stakeEntry.account.createdTs, + lastAccountedTs: new BN(0), + lastRewardAmount: new BN(0), + lastRewardPeriod: new BN(0), + accountedAmount: new BN(0), + claimedAmount: new BN(0), + buffer: [], + }; +}; + +export const calcRewards = ( + rewardEntryAccount: ProgramAccount | undefined, + stakeEntryAccount: ProgramAccount, + rewardPoolAccount: ProgramAccount, +) => { + const rewardEntry: RewardEntry = + rewardEntryAccount?.account ?? createDefaultRewardEntry(stakeEntryAccount, rewardPoolAccount); + const stakeEntry = stakeEntryAccount.account; + const rewardPool = rewardPoolAccount.account; + + const rewardEntryAccumulator = new RewardEntryAccumulator(rewardEntry); + if (rewardEntryAccumulator.createdTs.lt(stakeEntry.createdTs)) { + throw new Error("InvalidRewardEntry"); + } + + const currTs = Math.floor(Date.now() / 1000); + + const stakedTs = rewardPool.createdTs ? BN.max(stakeEntry.createdTs, rewardPool.createdTs) : stakeEntry.createdTs; + const claimableTs = stakeEntry.closedTs.gtn(0) ? stakeEntry.closedTs : new BN(currTs); + + const amountUpdated = + !rewardPool.rewardAmount.eq(rewardPool.lastRewardAmount) && + rewardPool.lastAmountUpdateTs.gt(stakeEntry.createdTs) && + rewardPool.lastAmountUpdateTs.gt(stakeEntry.closedTs); + const periodUpdated = + !rewardPool.rewardPeriod.eq(rewardPool.lastRewardPeriod) && + rewardPool.lastPeriodUpdateTs.gt(stakeEntry.createdTs) && + rewardPool.lastPeriodUpdateTs.gt(stakeEntry.closedTs); + + if (amountUpdated || periodUpdated) { + let firstUpdateTs: BN, secondUpdateTs: BN, rewardAmount: BN, rewardPeriod: BN; + if (amountUpdated && periodUpdated) { + if (rewardPool.lastAmountUpdateTs.lt(rewardPool.lastPeriodUpdateTs)) { + firstUpdateTs = rewardPool.lastAmountUpdateTs; + secondUpdateTs = rewardPool.lastPeriodUpdateTs; + rewardAmount = rewardPool.rewardAmount; + rewardPeriod = rewardEntryAccumulator.lastRewardPeriod; + } else { + firstUpdateTs = rewardPool.lastPeriodUpdateTs; + secondUpdateTs = rewardPool.lastAmountUpdateTs; + rewardAmount = rewardEntryAccumulator.lastRewardAmount; + rewardPeriod = rewardPool.rewardPeriod; + } + } else if (amountUpdated) { + firstUpdateTs = new BN(0); + secondUpdateTs = rewardPool.lastAmountUpdateTs; + rewardAmount = rewardEntryAccumulator.lastRewardAmount; + rewardPeriod = rewardEntryAccumulator.lastRewardPeriod; + } else { + firstUpdateTs = new BN(0); + secondUpdateTs = rewardPool.lastPeriodUpdateTs; + rewardAmount = rewardEntryAccumulator.lastRewardAmount; + rewardPeriod = rewardEntryAccumulator.lastRewardPeriod; + } + + if (firstUpdateTs.gtn(0)) { + const firstAccountableAmount = rewardEntryAccumulator.getAccountableAmount( + stakedTs, + firstUpdateTs, + stakeEntry.effectiveAmount, + rewardEntryAccumulator.lastRewardAmount, + rewardEntryAccumulator.lastRewardPeriod, + ); + rewardEntryAccumulator.addAccountedAmount(firstAccountableAmount); + rewardEntryAccumulator.lastAccountedTs = rewardEntryAccumulator.getLastAccountedTs( + stakedTs, + firstUpdateTs, + rewardPool.lastRewardPeriod, + ); + } + const secondAccountableAmount = rewardEntryAccumulator.getAccountableAmount( + stakedTs, + secondUpdateTs, + stakeEntry.effectiveAmount, + rewardAmount, + rewardPeriod, + ); + rewardEntryAccumulator.addAccountedAmount(secondAccountableAmount); + rewardEntryAccumulator.lastAccountedTs = rewardEntryAccumulator.getLastAccountedTs( + stakedTs, + secondUpdateTs, + rewardPeriod, + ); + } + + const accountableAmount = rewardEntryAccumulator.getAccountableAmount( + stakedTs, + claimableTs, + stakeEntry.effectiveAmount, + rewardPool.rewardAmount, + rewardPool.rewardPeriod, + ); + rewardEntryAccumulator.addAccountedAmount(accountableAmount); + + return rewardEntryAccumulator.getClaimableAmount(); +}; diff --git a/packages/staking/solana/utils.ts b/packages/staking/solana/utils.ts index 9b3ea1d..0f574da 100644 --- a/packages/staking/solana/utils.ts +++ b/packages/staking/solana/utils.ts @@ -1,88 +1,16 @@ import { TransferFeeConfig } from "@solana/spl-token"; -import { Connection, PublicKey } from "@solana/web3.js"; +import { Connection } from "@solana/web3.js"; // eslint-disable-next-line no-restricted-imports import BN from "bn.js"; import { - CONFIG_PREFIX, DEFAULT_FEE_BN, FEE_PRECISION_FACTOR_BN, - FEE_VALUE_PREFIX, - REWARD_ENTRY_PREFIX, - REWARD_POOL_PREFIX, - REWARD_VAULT_PREFIX, SCALE_PRECISION_FACTOR, SCALE_PRECISION_FACTOR_BN, - STAKE_ENTRY_PREFIX, - STAKE_MINT_PREFIX, - STAKE_POOL_PREFIX, - STAKE_VAULT_PREFIX, U64_MAX, } from "./constants.js"; -export const deriveStakePoolPDA = ( - programId: PublicKey, - mint: PublicKey, - authority: PublicKey, - nonce: number, -): PublicKey => { - return PublicKey.findProgramAddressSync( - [STAKE_POOL_PREFIX, mint.toBuffer(), authority.toBuffer(), new BN(nonce).toArrayLike(Buffer, "le", 1)], - programId, - )[0]; -}; - -export const deriveStakeVaultPDA = (programId: PublicKey, stakePool: PublicKey): PublicKey => { - return PublicKey.findProgramAddressSync([STAKE_VAULT_PREFIX, stakePool.toBuffer()], programId)[0]; -}; - -export const deriveStakeMintPDA = (programId: PublicKey, stakePool: PublicKey): PublicKey => { - return PublicKey.findProgramAddressSync([STAKE_MINT_PREFIX, stakePool.toBuffer()], programId)[0]; -}; - -export const deriveStakeEntryPDA = ( - programId: PublicKey, - stakePool: PublicKey, - authority: PublicKey, - nonce: number, -): PublicKey => { - return PublicKey.findProgramAddressSync( - [STAKE_ENTRY_PREFIX, stakePool.toBuffer(), authority.toBuffer(), new BN(nonce).toArrayLike(Buffer, "le", 4)], - programId, - )[0]; -}; - -export const deriveRewardPoolPDA = ( - programId: PublicKey, - stakePool: PublicKey, - mint: PublicKey, - nonce: number, -): PublicKey => { - return PublicKey.findProgramAddressSync( - [REWARD_POOL_PREFIX, stakePool.toBuffer(), mint.toBuffer(), new BN(nonce).toArrayLike(Buffer, "le", 1)], - programId, - )[0]; -}; - -export const deriveRewardVaultPDA = (programId: PublicKey, rewardPool: PublicKey): PublicKey => { - return PublicKey.findProgramAddressSync([REWARD_VAULT_PREFIX, rewardPool.toBuffer()], programId)[0]; -}; - -export const deriveRewardEntryPDA = (programId: PublicKey, rewardPool: PublicKey, stakeEntry: PublicKey): PublicKey => { - return PublicKey.findProgramAddressSync( - [REWARD_ENTRY_PREFIX, rewardPool.toBuffer(), stakeEntry.toBuffer()], - programId, - )[0]; -}; - -export const deriveConfigPDA = (programId: PublicKey): PublicKey => { - return PublicKey.findProgramAddressSync([CONFIG_PREFIX], programId)[0]; -}; - -export const deriveFeeValuePDA = (programId: PublicKey, target: PublicKey): PublicKey => { - return PublicKey.findProgramAddressSync([FEE_VALUE_PREFIX, target.toBuffer()], programId)[0]; -}; - export const calculateStakeWeight = (minDuration: BN, maxDuration: BN, maxWeight: BN, duration: BN) => { const durationSpan = maxDuration.sub(minDuration); if (durationSpan.eq(new BN(0))) {