diff --git a/package.json b/package.json index a8aeab9f..a6518371 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "testFriktion": "mocha --require ts-node/register --timeout 10000000 ./test/friktion.ts", "testNftFinance": "mocha --require ts-node/register --timeout 10000000 ./test/nftFinance.ts", "testLido": "mocha --require ts-node/register --timeout 10000000 ./test/lido.ts", - "testMarinade": "mocha --require ts-node/register --timeout 10000000 ./test/marinade.ts" + "testMarinade": "mocha --require ts-node/register --timeout 10000000 ./test/marinade.ts", + "testWhirlpools": "mocha --require ts-node/register --timeout 10000000 ./test/whirlpools.ts" }, "keywords": [], "author": "", diff --git a/src/index.ts b/src/index.ts index 9bf2c61a..179c3626 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,3 +13,4 @@ export * as nftFinance from "./nftFinance"; export * as friktion from "./friktion"; export * as lido from "./lido"; export * as marinade from "./marinade"; +export * as whirlpools from "./whirlpools"; diff --git a/src/types.ts b/src/types.ts index 77b72cad..57e62579 100644 --- a/src/types.ts +++ b/src/types.ts @@ -77,6 +77,18 @@ export interface PageConfig { pageIndex: number; } +export interface ICLPoolInfo { + poolId: PublicKey; + tokenAMint: PublicKey; + tokenBMint: PublicKey; +} + +export interface ICLPositionInfo { + positionId: PublicKey; + mint: PublicKey; + userKey: PublicKey; +} + export enum PoolDirection { Obverse, Reverse, @@ -221,3 +233,17 @@ export interface IInstanceVault { parseWithdrawer?(data: Buffer, withdrawerId: PublicKey): IWithdrawerInfo; getAllWithdrawers?(connection: Connection, userKey: PublicKey): Promise; } + +export interface IInstanceCLPool { + getAllPools(connection: Connection, page?: PageConfig): Promise; + getAllPoolWrappers(connection: Connection, page?: PageConfig): Promise; + getPool(connection: Connection, poolId: PublicKey): Promise; + getPoolWrapper(connection: Connection, poolId: PublicKey): Promise; + parsePool(data: Buffer, poolId: PublicKey): ICLPoolInfo; + getPosition(connection: Connection, positionId: PublicKey): Promise; + getAllPositions(connection: Connection, userKey: PublicKey): Promise; + parsePosition(data: Buffer, positionId: PublicKey): ICLPositionInfo; +} +export interface ICLPoolWrapper { + poolInfo: ICLPoolInfo; +} diff --git a/src/whirlpools/ids.ts b/src/whirlpools/ids.ts new file mode 100644 index 00000000..7d2b787e --- /dev/null +++ b/src/whirlpools/ids.ts @@ -0,0 +1,5 @@ +import { PublicKey } from "@solana/web3.js"; + +export const ORCA_WHIRLPOOLS_PROGRAM_ID = new PublicKey("whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc"); + +export const ORCA_WHIRLPOOLS_CONFIG = new PublicKey("2LecshUwdy9xi7meFgHtFJQNSKk4KdTrcpvaB56dP2NQ"); diff --git a/src/whirlpools/index.ts b/src/whirlpools/index.ts new file mode 100644 index 00000000..0e8d34ee --- /dev/null +++ b/src/whirlpools/index.ts @@ -0,0 +1,95 @@ +export * from "./ids"; +export * from "./infos"; +export * from "./layouts"; +import { PublicKey } from "@solana/web3.js"; +import BN from "bn.js"; +import { ICLPoolInfo, ICLPositionInfo } from "../types"; + +export interface PoolInfo extends ICLPoolInfo { + poolId: PublicKey; + whirlpoolBump: number; + config: WhirlpoolConfig; + tickSpacing: number; + tickSpacingSeed0: number; + tickSpacingSeed1: number; + feeRate: number; + protocolFeeRate: number; + liquidity: BN; + sqrtPrice: BN; + tickCurrentIndex: number; + protocolFeeOwedA: BN; + protocolFeeOwedB: BN; + tokenMintA: PublicKey; + tokenVaultA: PublicKey; + feeGrowthGlobalA: BN; + tokenMintB: PublicKey; + tokenVaultB: PublicKey; + feeGrowthGlobalB: BN; + rewardLastUpdatedTimestamp: BN; + reward0: RewardInfo; + reward1: RewardInfo; + reward2: RewardInfo; + tickArray: Tick[]; +} + +export interface PositionInfo extends ICLPositionInfo { + positionId: PublicKey; + poolId: PublicKey; + positionMint: PublicKey; + liquidity: BN; + tickLowerIndex: number; + tickUpperIndex: number; + feeGrowthCheckpointA: BN; + feeOwedA: BN; + feeGrowthCheckpointB: BN; + feeOwedB: BN; + rewardInfo0: PositionReward; + rewardInfo1: PositionReward; + rewardInfo2: PositionReward; +} + +export interface WhirlpoolConfig { + configId: PublicKey; + feeAuthority: PublicKey; + collectProtocolFeesAuthority: PublicKey; + rewardEmissionsSuperAuthority: PublicKey; + defaultProtocolFeeRate: number; +} + +export interface FeeConfig { + configId: PublicKey; + defaultProtocolFeeRate: PublicKey; + tickSpacing: number; + defaultFeeRate: number; +} + +export interface Tick { + tickId: PublicKey; + index: number; + initialized: boolean; + liquidityGross: BN; + liquidityNet: BN; + feeGrowthOutsideA: BN; + feeGrowthOutsideB: BN; + rewardGrowthsOutside0: BN; + rewardGrowthsOutside1: BN; + rewardGrowthsOutside2: BN; +} +export interface RewardInfo { + mint: PublicKey; + vault: PublicKey; + authority: PublicKey; + emissionsPerSecondX64: BN; + growthGlobalX64: BN; +} + +export interface PositionReward { + growthInsideCheckpoint: BN; + amountOwed: BN; +} + +export interface TickArray { + startTickIndex: number; + ticks: Tick[]; + whirlpool: PublicKey; +} diff --git a/src/whirlpools/infos.ts b/src/whirlpools/infos.ts new file mode 100644 index 00000000..3e6ae965 --- /dev/null +++ b/src/whirlpools/infos.ts @@ -0,0 +1,150 @@ +import { + Connection, + MemcmpFilter, + GetProgramAccountsConfig, + DataSizeFilter, + PublicKey, + GetProgramAccountsFilter, +} from "@solana/web3.js"; +import BN from "bn.js"; +import { ICLPoolWrapper, ICLPositionInfo, IInstanceCLPool, PageConfig } from "../types"; +import { CONFIG_LAYOUT, POSITION_LAYOUT, TICK_ARRAY_LAYOUT, WHIRLPOOL_LAYOUT } from "./layouts"; +import { ORCA_WHIRLPOOLS_CONFIG, ORCA_WHIRLPOOLS_PROGRAM_ID } from "./ids"; +import * as types from "."; +import { AccountLayout, MintLayout, TOKEN_PROGRAM_ID } from "@solana/spl-token-v2"; + +let infos: IInstanceCLPool; + +infos = class InstanceWhirlpools { + static async getAllPools(connection: Connection, page?: PageConfig): Promise { + const wpConfig = await getConfig(connection); + let filters: GetProgramAccountsFilter[] = [{ memcmp: { offset: 8, bytes: wpConfig.configId.toString() } }]; + + const config: GetProgramAccountsConfig = { filters: filters }; + let whirlpoolInfos = await connection.getProgramAccounts(ORCA_WHIRLPOOLS_PROGRAM_ID, config); + let ticksMaps = await getAllTickMap(connection); + return whirlpoolInfos + .filter((info) => info.account.data.length > 600) + .map((whirlpoolInfo) => { + const poolInfo = this.parsePool(whirlpoolInfo.account.data, whirlpoolInfo.pubkey); + return { + ...poolInfo, + config: wpConfig, + tickArray: ticksMaps.has(poolInfo.poolId.toString()) ? ticksMaps.get(poolInfo.poolId.toString())! : [], + }; + }); + } + static async getPool(connection: Connection, poolId: PublicKey): Promise { + const wpConfig = await getConfig(connection); + let ticksMaps = await getAllTickMap(connection); + const poolInfoRaw = await connection.getAccountInfo(poolId); + let poolInfo = this.parsePool(poolInfoRaw!.data, poolId); + + return { + ...poolInfo, + config: wpConfig, + tickArray: ticksMaps.has(poolInfo.poolId.toString()) ? ticksMaps.get(poolInfo.poolId.toString())! : [], + }; + } + static async getPoolWrapper(connection: Connection, poolId: PublicKey): Promise { + const pool = await this.getPool(connection, poolId); + return new PoolInfoWrapper(pool); + } + + static parsePool(data: Buffer, infoPubkey: PublicKey): types.PoolInfo { + if (data.length < WHIRLPOOL_LAYOUT.span) throw new Error("Invalid pool data"); + let parsed = WHIRLPOOL_LAYOUT.decode(data) as types.PoolInfo; + return { ...parsed, poolId: infoPubkey, tokenAMint: parsed.tokenMintA, tokenBMint: parsed.tokenMintB }; + } + static async getAllPoolWrappers(connection: Connection, page?: PageConfig): Promise { + return (await this.getAllPools(connection, page)).map((pool) => new PoolInfoWrapper(pool)); + } + static async getPosition(connection: Connection, positionId: PublicKey): Promise { + const positionInfoRaw = await connection.getAccountInfo(positionId); + + return this.parsePosition(positionInfoRaw!.data, positionId); + } + static async getAllPositions(connection: Connection, userKey: PublicKey): Promise { + let allUserTokenMintSet = new Map(); + (await connection.getTokenAccountsByOwner(userKey, { programId: TOKEN_PROGRAM_ID })).value.forEach((info) => { + let rawAccount = AccountLayout.decode(info.account.data); + if (rawAccount.amount.toString() == "0") return false; + allUserTokenMintSet.set(rawAccount.mint.toString(), new BN(rawAccount.amount.toString())); + }); + const sizeFilter: DataSizeFilter = { + dataSize: POSITION_LAYOUT.span, + }; + let filters: GetProgramAccountsFilter[] = [sizeFilter]; + let allPositionRaw = await connection.getProgramAccounts(ORCA_WHIRLPOOLS_PROGRAM_ID, { filters: filters }); + return allPositionRaw + .map((info) => { + return this.parsePosition(info.account.data, info.pubkey); + }) + .filter((info) => { + return allUserTokenMintSet.has(info.mint.toString()); + }); + } + static parsePosition(data: Buffer, positionId: PublicKey): ICLPositionInfo { + if (data.length < POSITION_LAYOUT.span) throw new Error("Invalid position data"); + return { ...POSITION_LAYOUT.decode(data), positionId: positionId } as ICLPositionInfo; + } +}; +export class PoolInfoWrapper implements ICLPoolWrapper { + constructor(public poolInfo: types.PoolInfo) {} + getPositionTokenAmount(position: types.PositionInfo) { + let liquidity = position.liquidity; + let currentTick = this.poolInfo.tickCurrentIndex; + let ticks = this.poolInfo.tickArray.filter( + (tick) => tick.index >= position.tickLowerIndex && tick.index <= position.tickUpperIndex + ); + } +} + +export async function getConfig(connection: Connection): Promise { + let configInfo = await connection.getAccountInfo(ORCA_WHIRLPOOLS_CONFIG); + if (configInfo) { + let parsed = CONFIG_LAYOUT.decode(configInfo.data); + return { ...parsed, configId: ORCA_WHIRLPOOLS_CONFIG } as types.WhirlpoolConfig; + } else { + throw new Error("Config not found"); + } +} +export async function getAllTickMap(connection: Connection, whirlpoolId?: PublicKey) { + const sizeFilter: DataSizeFilter = { + dataSize: TICK_ARRAY_LAYOUT.span, + }; + let filters: GetProgramAccountsFilter[] = [sizeFilter]; + if (whirlpoolId) { + filters.push({ memcmp: { offset: TICK_ARRAY_LAYOUT.span - 32, bytes: whirlpoolId.toBase58() } }); + } + const config: GetProgramAccountsConfig = { filters: filters }; + let tickArrayInfos = await connection.getProgramAccounts(ORCA_WHIRLPOOLS_PROGRAM_ID, config); + let tickArraysMap = new Map(); + let ticksMaps = new Map(); + for (let tickArrayInfo of tickArrayInfos) { + let tickArray = TICK_ARRAY_LAYOUT.decode(tickArrayInfo.account.data) as types.TickArray; + if (tickArraysMap.has(tickArray.whirlpool.toString())) { + let array = tickArraysMap.get(tickArray.whirlpool.toString())!; + array.push(tickArray); + } else { + tickArraysMap.set(tickArray.whirlpool.toString(), [tickArray]); + } + } + tickArraysMap.forEach((tickArrays, whirlpoolId) => { + let ticks: types.Tick[] = []; + tickArrays.sort((a, b) => a.startTickIndex - b.startTickIndex); + tickArrays.forEach((tickArray) => { + for (let i = 0; i < tickArray.ticks.length; i++) { + let tick = tickArray.ticks[i]; + tick.index = i + tickArray.startTickIndex; + if (tick) { + ticks.push(tick); + } + } + }); + ticksMaps.set(whirlpoolId, ticks); + }); + return ticksMaps; +} + +export { infos }; diff --git a/src/whirlpools/layouts.ts b/src/whirlpools/layouts.ts new file mode 100644 index 00000000..91eb7b22 --- /dev/null +++ b/src/whirlpools/layouts.ts @@ -0,0 +1,81 @@ +import { publicKey, u8, u64, u128, u16, i32, bool, i128, str } from "@project-serum/borsh"; +//@ts-ignore +import { blob, seq, struct } from "buffer-layout"; + +export const CONFIG_LAYOUT = struct([ + blob(8, "discriminator"), + publicKey("feeAuthority"), + publicKey("collectProtocolFeesAuthority"), + publicKey("rewardEmissionsSuperAuthority"), + u16("defaultProtocolFeeRate"), +]); +export const REWARD_LAYOUT = [ + publicKey("mint"), + publicKey("vault"), + publicKey("authority"), + u128("emissionsPerSecondX64"), + u128("growthGlobalX64"), +]; +export const POSITION_REWARD_LAYOUT = [u128("growthInsideCheckpoint"), u64("amountOwed")]; + +export const FEE_LAYOUT = struct([publicKey("whirlpoolsConfig"), u16("tickSpacing"), u16("defaultFeeRate")]); + +export const POSITION_LAYOUT = struct([ + blob(8, "discriminator"), + publicKey("whirlpool"), + publicKey("mint"), + u128("liquidity"), + i32("tickLowerIndex"), + i32("tickUpperIndex"), + u128("feeGrowthCheckpointA"), + u64("feeOwedA"), + u128("feeGrowthCheckpointB"), + u64("feeOwedB"), + struct(POSITION_REWARD_LAYOUT, "rewardInfo0"), + struct(POSITION_REWARD_LAYOUT, "rewardInfo1"), + struct(POSITION_REWARD_LAYOUT, "rewardInfo2"), +]); + +export const WHIRLPOOL_LAYOUT = struct([ + blob(8, "discriminator"), + publicKey("whirlpoolsConfig"), + u8("whirlpoolBump"), + u16("tickSpacing"), + u8("tickSpacingSeed0"), + u8("tickSpacingSeed1"), + u16("feeRate"), + u16("protocolFeeRate"), + u128("liquidity"), + u128("sqrtPrice"), + i32("tickCurrentIndex"), + u64("protocolFeeOwedA"), + u64("protocolFeeOwedB"), + publicKey("tokenMintA"), + publicKey("tokenVaultA"), + u128("feeGrowthGlobalA"), + publicKey("tokenMintB"), + publicKey("tokenVaultB"), + u128("feeGrowthGlobalB"), + u64("rewardLastUpdatedTimestamp"), + struct(REWARD_LAYOUT, "reward0"), + struct(REWARD_LAYOUT, "reward1"), + struct(REWARD_LAYOUT, "reward2"), +]); + +export const TICK_LAYOUT = struct([ + bool("initialized"), + i128("liquidityNet"), + u128("liquidityGross"), + u128("feeGrowthOutsideA"), + u128("feeGrowthOutsideB"), + u128("rewardGrowthsOutside0"), + u128("rewardGrowthsOutside1"), + u128("rewardGrowthsOutside2"), +]); + +export const TICK_ARRAY_LAYOUT = struct([ + blob(8, "discriminator"), + i32("startTickIndex"), + seq(TICK_LAYOUT, 88, "ticks"), + publicKey("whirlpool"), +]); diff --git a/test/whirlpools.ts b/test/whirlpools.ts new file mode 100644 index 00000000..2e4859d4 --- /dev/null +++ b/test/whirlpools.ts @@ -0,0 +1,48 @@ +import { PublicKey, Connection, Keypair } from "@solana/web3.js"; +import { whirlpools } from "../src"; +import fs from "fs"; +import os from "os"; + +import BN from "bn.js"; +import { NATIVE_MINT } from "@solana/spl-token-v2"; + +describe("Whirlpools", () => { + // const connection = new Connection("https://rpc-mainnet-fork.dappio.xyz", { + // commitment, + // wsEndpoint: "wss://rpc-mainnet-fork.dappio.xyz/ws", + // }); + const connection = new Connection("https://rpc.ankr.com/solana", { + wsEndpoint: "wss://solana-mainnet.g.alchemy.com/v2/LhVpGFciWuulV_aAmsr6HbBlddZVM2TX", + commitment: "confirmed", + confirmTransactionInitialTimeout: 180 * 1000, + }); + // const connection = new Connection("https://ssc-dao.genesysgo.net", { + // commitment: "confirmed", + // confirmTransactionInitialTimeout: 180 * 1000, + // }); + // const connection = new Connection("https://api.mainnet-beta.solana.com", { + // commitment: "confirmed", + // confirmTransactionInitialTimeout: 180 * 1000, + // }); + // const connection = new Connection("https://rpc-mainnet-fork.epochs.studio", { + // commitment: "confirmed", + // confirmTransactionInitialTimeout: 180 * 1000, + // wsEndpoint: "wss://rpc-mainnet-fork.epochs.studio/ws", + // }); + + // const options = anchor.AnchorProvider.defaultOptions(); + + // const provider = new anchor.AnchorProvider(connection, wallet, options); + + // anchor.setProvider(provider); + + it("test", async () => { + //console.log(whirlpools.WHIRLPOOL_LAYOUT); + let wallet = new PublicKey("G9on1ddvCc8xqfk2zMceky2GeSfVfhU8JqGHxNEWB5u4"); + let pools = (await whirlpools.infos.getAllPools(connection)) as whirlpools.PoolInfo[]; + console.log(pools.length); + let positions = (await whirlpools.infos.getAllPositions(connection, wallet)) as whirlpools.PositionInfo[]; + + positions.forEach((p) => console.log(p.positionId.toString(), p.liquidity.toString())); + }); +});