Skip to content

Commit

Permalink
add calculate reward amount helper (#224)
Browse files Browse the repository at this point in the history
* add calculate reward amount helper
  • Loading branch information
Yolley authored Nov 5, 2024
1 parent 415bf87 commit 5476a60
Show file tree
Hide file tree
Showing 5 changed files with 213 additions and 26 deletions.
48 changes: 47 additions & 1 deletion packages/staking/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ const { metadataId: stakePoolPda } = await client.createStakePool({

#### Create a rewardPool pool
```typescript
import { calculateRewardAmountFromRate } from "@streamflow/staking";

/*
[0;256) derive reward pool PDA account address.
If reward pool with the same mint already exists, it is required to pick a vacant nonce
Expand All @@ -132,6 +134,14 @@ const nonce = 0;
Amount of rewarding tokens stakers get in return for staking exactly 1 token to the staking pool
*/
const rewardAmount = new BN(100);
/*
Alternatively you may want to calculate the correct reward amount from desired reward rate taking into account stake pool and reward pools tokes decimals
*/
const rewardRate = 0.0025;
const stakeTokenDecimals = 9;
const rewardTokenDecimals = 6;
// For every effectively Staked 1 WHOLE token 0.0025 of Reward Token will be distributed
const rewardAmount = calculateRewardAmountFromRate(rewardRate, stakeTokenDecimals, rewardTokenDecimals);
/*
1 day - Unix time in seconds. Period for rewarding stakers. Period starts counting from the moment of staking
*/
Expand All @@ -154,6 +164,42 @@ client.createRewardPool({
```


#### Reward Amount configuration (in-depth)

`rewardAmount` represents a 10^9 fraction of a raw token distributed for every **effective staked raw token** - it's important to account for both reward and stake token decimals when creating staking pool because of that.

Example with only raw tokens: if `rewardAmount` is configured to be `1_000` and user staked `1_000_000_000` Raw Tokens with a weight of `2` (in the actual protocol this number will be represented as `2_000_000_000`), it means that the effective number of raw tokens staked is `2_000_000_000` and on every reward distribution user will get `2_000_000_000 * 1_000 / 10^9 = 200` Raw Tokens;

Examples with decimals:

RT - Reward Token
ST - Stake Pool Token
P - fixed `rewardAmount` precision of 9

User wants to set reward amount of `0.003` for every effective staked whole token, depending on number of decimals RT and ST have configuration may look different:

1. RT with 6 decimals, ST with 6 decimals.
- `0.003` of RT is `3_000` raw tokens;
- ST has 6 decimals while P is 9, therefore `9 - 6 = 3`;
- We need to add 3 decimals to the `rewardAmount` for proper distribution making it `3_000_000`;
2. RT with 12 decimals, ST with 12 decimals.
- `0.003` of RT is `3_000_000_000` raw tokens;
- ST has 12 decimals while P is 9, therefore `9 - 12 = -3`;
- We need to remove decimals from the raw token to be distributed making `rewardAmount` = `3_000_000`;
3. RT with 5 decimals, ST with 7 decimals.
- `0.003` of RT is `300` raw tokens;
- ST has 7 decimals while P is 9, therefore `9 - 7 = 2`;
- We need to add 2 decimals making `rewardAmount` = `30_000`;
4. RT with 9 decimals, ST with 3 decimals.
- `0.003` of RT is `3_000_000` raw tokens;
- the difference between RT and ST decimals is `9 - 3 = 6`;
- ST has 3 decimals while P is 9, therefore `9 - 3 = 6`;
- We need to add 6 decimals making `rewardAmount` = `3_000_000_000_000`;

We recommend to use the `calculateRewardAmountFromRate` function exposed by the sdk for the correct reward amount configuration.

**Also, some configurations where there is big difference between Stake Pool and Reward Pool token decimals may be unsupported, in this case the function will return 0, so be aware.**

#### Deposit/Stake to a stake pool
```typescript
/*
Expand Down Expand Up @@ -213,4 +259,4 @@ Streamflow Staking protocol program IDs

### IDLs
For further details you can consult with IDLs of protocols available at:
`@streamflow/staking/dist/esm/solana/descriptor`
`@streamflow/staking/dist/esm/solana/descriptor`
91 changes: 91 additions & 0 deletions packages/staking/__tests__/solana/rewards.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { getBN } from "@streamflow/common";
import { PublicKey } from "@solana/web3.js";
// eslint-disable-next-line no-restricted-imports
import BN from "bn.js";
import { describe, expect, test } from "vitest";

import {
RewardEntryAccumulator,
calculateRewardAmountFromRate,
calculateRewardRateFromAmount,
} from "../../solana/lib/rewards.js";
import { SCALE_PRECISION_FACTOR_BN } from "../../solana/constants.js";

const populateRewardEntry = (
effectiveStakedAmount?: BN,
rewardAmount?: BN,
rewardPeriod?: BN,
periods?: number,
): RewardEntryAccumulator => {
const rewardEntry = new RewardEntryAccumulator(
new BN(0),
new BN(0),
new BN(0),
PublicKey.default,
PublicKey.default,
new BN(0),
new BN(0),
new BN(0),
[],
);
if (effectiveStakedAmount && rewardAmount && rewardPeriod) {
rewardEntry.accountedAmount = rewardEntry.getAccountableAmount(
new BN(0),
rewardPeriod.muln(periods || 1),
effectiveStakedAmount,
rewardAmount,
rewardPeriod,
);
}
return rewardEntry;
};

describe("RewardEntryAccumulator", () => {
describe("getClaimableAmount", () => {
const testCases: [number, number, number, number, BN][] = [
[9, 9, 1, 0.0025, new BN(2_500_000)],
[6, 9, 1, 0.0025, new BN(2_500_000_000)],
[9, 6, 1, 0.0025, new BN(2_500)],
[1, 8, 1, 0.0025, new BN(25_000_000_000_000)],
];
testCases.forEach(([stakeTokenDecimals, rewardTokenDecimals, periods, rewardRate, expectedRewardAmount]) => {
test(`test decimals - ${stakeTokenDecimals}/${rewardTokenDecimals}/${periods}/${rewardRate}`, () => {
const stakedAmount = getBN(1, stakeTokenDecimals);
const effectiveStakedAmount = stakedAmount.mul(SCALE_PRECISION_FACTOR_BN);
const rewardPeriod = new BN(1);
const rewardAmount = calculateRewardAmountFromRate(rewardRate, stakeTokenDecimals, rewardTokenDecimals);
const rewardEntry = populateRewardEntry(effectiveStakedAmount, rewardAmount, rewardPeriod, periods);
const claimableAmount = rewardEntry.getClaimableAmount();

expect(rewardAmount.toString()).toEqual(expectedRewardAmount.toString());
expect(claimableAmount.toString()).toEqual(getBN(rewardRate, rewardTokenDecimals).muln(periods).toString());
expect(calculateRewardRateFromAmount(rewardAmount, stakeTokenDecimals, rewardTokenDecimals)).toEqual(
rewardRate,
);
});
});

test(`test decimals - negative difference`, () => {
let rewardAmount = calculateRewardAmountFromRate(0.0025, 18, 1);
expect(rewardAmount.toString()).toEqual(new BN(0).toString());

rewardAmount = calculateRewardAmountFromRate(0.0025, 12, 4);
expect(rewardAmount.toString()).toEqual(new BN(0).toString());
});

test(`test decimals - precision loss`, () => {
const stakeTokenDecimals = 12;
const rewardTokenDecimals = 6;
const stakedAmount = getBN(1, stakeTokenDecimals);
const effectiveStakedAmount = stakedAmount.mul(SCALE_PRECISION_FACTOR_BN);
const rewardPeriod = new BN(1);
const rewardAmount = calculateRewardAmountFromRate(0.0025, stakeTokenDecimals, rewardTokenDecimals);
const rewardEntry = populateRewardEntry(effectiveStakedAmount, rewardAmount, rewardPeriod);
const claimableAmount = rewardEntry.getClaimableAmount();

expect(rewardAmount.toString()).toEqual(new BN(2).toString());
expect(claimableAmount.toString()).toEqual(getBN(0.002, rewardTokenDecimals).toString());
expect(calculateRewardRateFromAmount(rewardAmount, stakeTokenDecimals, rewardTokenDecimals)).toEqual(0.002);
});
});
});
1 change: 1 addition & 0 deletions packages/staking/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"build": "rm -rf dist; pnpm run build:cjs && pnpm run build:esm",
"pack": "pnpm build && pnpm pack",
"lint": "eslint --fix .",
"test": "vitest",
"prepublishOnly": "npm run lint && npm run build"
},
"gitHead": "a37306eba0e762af096db642fa22f07194014cfd",
Expand Down
3 changes: 3 additions & 0 deletions packages/staking/solana/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export const DEFAULT_FEE = 19;
export const DEFAULT_FEE_BN = new BN(DEFAULT_FEE);
export const SCALE_PRECISION_FACTOR = 1_000_000_000;
export const SCALE_PRECISION_FACTOR_BN = new BN(SCALE_PRECISION_FACTOR);
export const REWARD_AMOUNT_DECIMALS = 9;
export const REWARD_AMOUNT_PRECISION_FACTOR = 1_000_000_000;
export const REWARD_AMOUNT_PRECISION_FACTOR_BN = new BN(REWARD_AMOUNT_PRECISION_FACTOR);
export const U64_MAX = 18446744073709551615n;
export const STAKE_ENTRY_DISCRIMINATOR = [187, 127, 9, 35, 155, 68, 86, 40];
export const STAKE_ENTRY_PREFIX = Buffer.from("stake-entry", "utf-8");
Expand Down
96 changes: 71 additions & 25 deletions packages/staking/solana/lib/rewards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ 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 { getBN, getNumberFromBN } from "@streamflow/common";

import { RewardEntry, StakeEntry, RewardPool } from "../types.js";
import { SCALE_PRECISION_FACTOR_BN } from "../constants.js";

export const REWARD_AMOUNT_PRECISION_FACTOR = new BN("1000000000");
import { RewardEntry, RewardPool, StakeEntry } from "../types.js";
import { REWARD_AMOUNT_DECIMALS, REWARD_AMOUNT_PRECISION_FACTOR_BN, SCALE_PRECISION_FACTOR_BN } from "../constants.js";

export class RewardEntryAccumulator implements RewardEntry {
lastAccountedTs: BN;
Expand All @@ -27,16 +26,40 @@ export class RewardEntryAccumulator implements RewardEntry {

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;
constructor(
lastAccountedTs: BN,
claimedAmount: BN,
accountedAmount: BN,
rewardPool: PublicKey,
stakeEntry: PublicKey,
createdTs: BN,
lastRewardAmount: BN,
lastRewardPeriod: BN,
buffer: number[],
) {
this.lastAccountedTs = lastAccountedTs;
this.claimedAmount = claimedAmount;
this.accountedAmount = accountedAmount;
this.rewardPool = rewardPool;
this.stakeEntry = stakeEntry;
this.createdTs = createdTs;
this.buffer = buffer;
this.lastRewardAmount = lastRewardAmount;
this.lastRewardPeriod = lastRewardPeriod;
}

static fromEntry(entry: RewardEntry): RewardEntryAccumulator {
return new this(
entry.lastAccountedTs,
entry.claimedAmount,
entry.accountedAmount,
entry.rewardPool,
entry.stakeEntry,
entry.createdTs,
entry.lastRewardAmount,
entry.lastRewardPeriod,
entry.buffer,
);
}

// Calculate accountable amount by calculating how many seconds have passed since last claim/stake time
Expand All @@ -58,18 +81,14 @@ export class RewardEntryAccumulator implements RewardEntry {

const claimablePerEffectiveStake = periodsPassed.mul(rewardAmount);

const accountableAmount = claimablePerEffectiveStake.mul(effectiveStakedAmount).div(SCALE_PRECISION_FACTOR_BN);

return accountableAmount;
return claimablePerEffectiveStake.mul(effectiveStakedAmount).div(SCALE_PRECISION_FACTOR_BN);
}

// Calculates claimable amount from accountable amount.
getClaimableAmount(): BN {
const claimedAmount = this.claimedAmount.mul(REWARD_AMOUNT_PRECISION_FACTOR);
const claimedAmount = this.claimedAmount.mul(REWARD_AMOUNT_PRECISION_FACTOR_BN);
const nonClaimedAmount = this.accountedAmount.sub(claimedAmount);
const claimableAmount = nonClaimedAmount.div(REWARD_AMOUNT_PRECISION_FACTOR);

return claimableAmount;
return nonClaimedAmount.div(REWARD_AMOUNT_PRECISION_FACTOR_BN);
}

// Get the time of the last unlock
Expand All @@ -78,9 +97,7 @@ export class RewardEntryAccumulator implements RewardEntry {
const totalSecondsPassed = claimableTs.sub(lastAccountedTs);
const periodsPassed = totalSecondsPassed.div(rewardPeriod);
const periodsToSeconds = periodsPassed.mul(rewardPeriod);
const currClaimTs = lastAccountedTs.add(periodsToSeconds);

return currClaimTs;
return lastAccountedTs.add(periodsToSeconds);
}

// Adds accounted amount
Expand Down Expand Up @@ -121,7 +138,7 @@ export const calcRewards = (
const stakeEntry = stakeEntryAccount.account;
const rewardPool = rewardPoolAccount.account;

const rewardEntryAccumulator = new RewardEntryAccumulator(rewardEntry);
const rewardEntryAccumulator = RewardEntryAccumulator.fromEntry(rewardEntry);
if (rewardEntryAccumulator.createdTs.lt(stakeEntry.createdTs)) {
throw new Error("InvalidRewardEntry");
}
Expand Down Expand Up @@ -207,3 +224,32 @@ export const calcRewards = (

return rewardEntryAccumulator.getClaimableAmount();
};

export const calculateRewardRateFromAmount = (
rewardAmount: BN,
stakeTokenDecimals: number,
rewardTokenDecimals: number,
) => {
const decimals = rewardTokenDecimals + (REWARD_AMOUNT_DECIMALS - stakeTokenDecimals);
return getNumberFromBN(rewardAmount, decimals);
};

export const calculateRewardAmountFromValue = (rewardTokenValue: BN, stakeTokenDecimals: number) => {
const decimalsDiff = REWARD_AMOUNT_DECIMALS - stakeTokenDecimals;
if (decimalsDiff === 0) {
return rewardTokenValue;
}
const diffFactor = new BN(10).pow(new BN(Math.abs(decimalsDiff)));
if (decimalsDiff > 0) {
return rewardTokenValue.mul(diffFactor);
}
return rewardTokenValue.div(diffFactor);
};

export const calculateRewardAmountFromRate = (
rewardRate: number,
stakeTokenDecimals: number,
rewardTokenDecimals: number,
) => {
return calculateRewardAmountFromValue(getBN(rewardRate, rewardTokenDecimals), stakeTokenDecimals);
};

0 comments on commit 5476a60

Please sign in to comment.