diff --git a/.changeset/young-rocks-sort.md b/.changeset/young-rocks-sort.md new file mode 100644 index 00000000..97afcb69 --- /dev/null +++ b/.changeset/young-rocks-sort.md @@ -0,0 +1,5 @@ +--- +'@sei-js/core': minor +--- + +Add APR utilities diff --git a/packages/core/package.json b/packages/core/package.json index 3227ce41..cf60eb93 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -54,6 +54,7 @@ "bech32": "^2.0.0", "buffer": "^6.0.3", "elliptic": "^6.5.4", + "moment": "^2.30.1", "process": "^0.11.10", "readonly-date": "^1.0.0", "sha.js": "^2.4.11", @@ -65,7 +66,8 @@ "@babel/preset-env": "^7.22.20", "@babel/preset-typescript": "^7.22.15", "@types/elliptic": "^6.4.14", - "@types/sha.js": "^2.4.1" + "@types/sha.js": "^2.4.1", + "long5": "npm:long" }, "publishConfig": { "access": "public" diff --git a/packages/core/src/lib/utils/__tests__/apr.spec.ts b/packages/core/src/lib/utils/__tests__/apr.spec.ts new file mode 100644 index 00000000..cffefa60 --- /dev/null +++ b/packages/core/src/lib/utils/__tests__/apr.spec.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from '@jest/globals'; +import { getUpcomingMintTokens } from '../apr'; +import moment from 'moment'; +import Long from 'long5'; + +const releaseSchedule = [ + { + "token_release_amount": new Long(500000), + "start_date": "2023-10-01", + "end_date": "2023-10-20" + }, + { + "token_release_amount": new Long(500000), + "start_date": "2023-11-01", + "end_date": "2023-11-30" + }, + { + "token_release_amount": new Long(500000), + "start_date": "2023-11-30", + "end_date": "2023-12-31" + }, + { + "token_release_amount": new Long(10000000), + "start_date": "2024-01-01", + "end_date": "2024-12-31" + }, + { + "token_release_amount": new Long(10000000), + "start_date": "2025-01-01", + "end_date": "2025-12-31" + }, + { + "token_release_amount": new Long(8500000), + "start_date": "2023-01-01", + "end_date": "2023-09-30" + }, +] + +describe('getUpcomingMintTokens', () => { + // Test from 2023-01-01 to 2024-01-01 (exclusive). + // This should get the full distribution from 10/01 - 10/20, 11/01-11/30, and 11/30-12/31 + it('should return a correct amount of tokens', () => { + const result = getUpcomingMintTokens(moment("2023-01-01"), 365, releaseSchedule); + + expect(result).toBe(10000000); + }); + + it('handles gaps in releases', () => { + // Test from 2023-10-11 to 2023-11-16 (exclusive). + // This should get half the distribution from 10/1 - 10/20 and half from the release from 11/1 - 11/30 + const result = getUpcomingMintTokens(moment("2023-10-11"), 36, releaseSchedule); + + expect(result).toBe(500000); + }); + + it('handles input windows within the same release', () => { + // Test any 73 day window (1/5 of 365 days) in 2025. + // This should get 20% the distribution from 2025-01-01 to 2025-12-31 + const result = getUpcomingMintTokens(moment("2025-04-13"), 73, releaseSchedule); + + expect(result).toBe(2000000); + }); + + it('handles input windows that start before any schedule', () => { + // Test from 2022-12-15 - 2023-10-1 (exclusive). + // This should get 100% the distribution from 2023-01-01 to 2023-09-30 + const result = getUpcomingMintTokens(moment("2022-12-15"), 289, releaseSchedule); + + expect(result).toBe(8500000); + }); + + it('handles input windows that end after any schedule', () => { + // Test the last 73 days of 2025 to sometime in 2026. + // This should get 20% the distribution from 2025-01-01 to 2025-12-31 + const result = getUpcomingMintTokens(moment("2025-10-20"), 365, releaseSchedule); + + expect(result).toBe(2000000); + }); +}); \ No newline at end of file diff --git a/packages/core/src/lib/utils/apr.ts b/packages/core/src/lib/utils/apr.ts new file mode 100644 index 00000000..ea97e819 --- /dev/null +++ b/packages/core/src/lib/utils/apr.ts @@ -0,0 +1,132 @@ +import { ScheduledTokenReleaseSDKType } from "@sei-js/proto/dist/types/codegen/seiprotocol/seichain/mint/v1beta1/mint"; +import { getQueryClient } from "../queryClient"; +import moment, { Moment } from 'moment'; +export type QueryClient = Awaited>; + +export async function estimateStakingAPR(queryClient: QueryClient) { + // Query number of bonded tokens + const pool = await getPool(queryClient); + const bondedTokens = Number(pool?.bonded_tokens); + + // Query mint schedule + const mintParams = await getMintParams(queryClient); + const mintSchedule = mintParams?.token_release_schedule; + + if (!mintSchedule || !pool) { + throw new Error("Failed to query mintSchedule or pool"); + } + + // Calculate number of tokens to be minted in the next year. + const upcomingMintTokens = getUpcomingMintTokens(moment(), 365, mintSchedule); + + // APR estimate is the number of tokens to be minted / current number of bonded tokens. + return upcomingMintTokens / bondedTokens +} + +// Helper function to query the staking pool. +export async function getPool(queryClient: QueryClient) { + try { + const result = await queryClient.cosmos.staking.v1beta1.pool({}); + return result.pool; + } catch (error) { + console.log(error); + } +} + +// Helper function to query the mint module params. +export async function getMintParams(queryClient: QueryClient) { + try { + const result = await queryClient.seiprotocol.seichain.mint.params({}); + return result.params; + } catch (error) { + console.log(error); + } +} + +// Gets the number of tokens that will be minted in the given window based on the given releaseSchedule. +// Assumes that releaseSchedule has no overlapping schedules. +export function getUpcomingMintTokens(startDate: Moment, days: number, releaseSchedule: ScheduledTokenReleaseSDKType[]): number { + // End date is the exclusive end date of the window to query. + // Ie. if start date is 2023-1-1 and days is 365, end date here will be 2024-1-1 so rewards will be calculated from 2023-1-1 to 2023-12-31 + const endDate = startDate.clone().add(days, 'days') + + // Sort release schedule in increasing order of start time. + let sortedReleaseSchedule: ReleaseSchedule[] = getSortedReleaseSchedule(releaseSchedule); + + var tokens: number = 0 + for (var release of sortedReleaseSchedule) { + // Skip all schedules that ended before today. + if (release.endDate.isBefore(startDate)) { + continue; + } + // If the start date is after end date, we have come to the end of all releases we should consider. + if (release.startDate.isAfter(endDate)) { + break; + } + // All releases from here are part of the window. + // The case where this release started before today. + if (release.startDate.isBefore(startDate)) { + + // Need to deduct 1 day from endDate to make it an inclusive end date. + let earlierInclusiveEndDate = moment.min(endDate.clone().subtract(1, "days"), release.endDate); + + // Number of days left in this release. + let daysLeft: number = calculateDaysInclusive(startDate, earlierInclusiveEndDate); + let totalPeriod: number = calculateDaysInclusive(release.startDate, release.endDate); + tokens += (daysLeft / totalPeriod) * release.tokenReleaseAmount; + } + + // The case where this release ends after our search window. + else if (release.endDate.isAfter(endDate)) { + let daysLeft: number = Math.round(endDate.diff(release.startDate, 'days', true)); + let totalPeriod: number = calculateDaysInclusive(release.startDate, release.endDate); + tokens += (daysLeft / totalPeriod) * release.tokenReleaseAmount; + } + + // In the final case, the entire period falls within our window. + else { + tokens += release.tokenReleaseAmount; + } + } + + return tokens; +} + +// Converts the releaseSchedule into ReleaseSchedule[] and sorts it by start date. +function getSortedReleaseSchedule(releaseSchedule: ScheduledTokenReleaseSDKType[]) { + let releaseScheduleTimes = releaseSchedule.map((schedule) => { + return createReleaseSchedule(schedule.start_date, schedule.end_date, schedule.token_release_amount); + }) + + // Sort release schedule in increasing order of start time. + let sortedReleaseSchedule = releaseScheduleTimes.sort((x, y) => { + if (x.startDate.isAfter(y.startDate)) { + return 1; + } + else if (y.startDate.isAfter(x.startDate)) { + return -1; + } + return 0; + }) + + return sortedReleaseSchedule; +} + +// Returns the number of days in the window inclusive of the start and end date. +function calculateDaysInclusive(startDate: Moment, endDate: Moment) { + return Math.round(endDate.diff(startDate, 'days', true)) + 1; +} + +interface ReleaseSchedule { + startDate: Moment; + endDate: Moment; + tokenReleaseAmount: number; +} + +function createReleaseSchedule(start_date: string, end_date: string, token_release_amount: Long): ReleaseSchedule { + return { + startDate: moment(start_date), + endDate: moment(end_date), + tokenReleaseAmount: Number(token_release_amount), + } +} \ No newline at end of file diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index b66fa8f4..ac9b0c32 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -7,6 +7,7 @@ "strict": true, "moduleResolution": "node", "allowSyntheticDefaultImports": true, + "esModuleInterop": true, "skipLibCheck": true, "noImplicitAny": false, "lib": ["ES6", "DOM"] diff --git a/yarn.lock b/yarn.lock index 79423558..f6ad9e71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11561,6 +11561,11 @@ loglevel@^1.6.0: resolved "https://registry.npmjs.org/loglevel/-/loglevel-1.8.1.tgz#5c621f83d5b48c54ae93b6156353f555963377b4" integrity sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg== +"long5@npm:long": + version "5.2.3" + resolved "https://registry.yarnpkg.com/long/-/long-5.2.3.tgz#a3ba97f3877cf1d778eccbcb048525ebb77499e1" + integrity sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q== + long@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" @@ -12072,6 +12077,11 @@ module-deps@^6.2.3: through2 "^2.0.0" xtend "^4.0.0" +moment@^2.30.1: + version "2.30.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" + integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== + monaco-editor@^0.38.0: version "0.38.0" resolved "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.38.0.tgz#7b3cd16f89b1b8867fcd3c96e67fccee791ff05c"