import { providers as multicallProviders } from '@0xsequence/multicall'; import BigNumberJs from 'bignumber.js'; import { BigNumberish, providers } from 'ethers'; import { uniqWith } from 'lodash'; import BaseService from '../commons/BaseService'; import { eEthereumTxType, transactionType } from '../commons/types'; import { PromiseOrValue } from '../types/common'; import { ApeCoinStaking } from './typechain/ApeCoinStaking'; import { ApeCoinStaking__factory } from './typechain/ApeCoinStaking__factory'; export type NftDepositInfo = ApeCoinStaking.SingleNftStruct; export type PairDepositInfo = ApeCoinStaking.PairNftDepositWithAmountStruct; export type BakcWithdrawInfo = ApeCoinStaking.PairNftWithdrawWithAmountStruct; type SinglePoolsDataProps = { rewardsPerHour: BigNumberJs; capPerPosition: BigNumberJs; apy: BigNumberJs; paraStakedNFTAmount: number; paraStakedApeAmount: BigNumberJs; totalStakedApeAmount: BigNumberJs; }; type PoolsDataProps = { APE: SinglePoolsDataProps; BAYC: SinglePoolsDataProps; MAYC: SinglePoolsDataProps; BAKC: SinglePoolsDataProps; }; type OverviewProps = { total: SinglePoolsDataProps; } & PoolsDataProps; export const POOL_ID_MAP = { APE: 0, BAYC: 1, MAYC: 2, BAKC: 3, }; const APE_COIN_PRECISION = 1e18; const APE_COIN_DECIMAL = 18; const calculateAmountByDecimal = ( tokenInfo: { amount: PromiseOrValue } & T, ): T => { return { ...tokenInfo, amount: new BigNumberJs(tokenInfo.amount as string) .shiftedBy(APE_COIN_DECIMAL) .toFixed(), }; }; export class ApeStakingService extends BaseService { instance: ApeCoinStaking; constructor(provider: providers.Provider, address: string) { const multicallProvider = new multicallProviders.MulticallProvider( provider, ); super(multicallProvider, ApeCoinStaking__factory); this.instance = this.getContractInstance(address); } public async depositApe(amount: string) { const populatedTransaction = this.instance.populateTransaction.depositSelfApeCoin(amount); return populatedTransaction; } public async depositBayc(depositInfos: NftDepositInfo[], user: string) { const tokenInfos = depositInfos.map(tokenInfo => calculateAmountByDecimal(tokenInfo), ); const populatedTransaction = this.instance.populateTransaction.depositBAYC(tokenInfos); const txCallback: () => Promise = this.generateTxCallback({ rawTxMethod: async () => populatedTransaction, from: user, }); return { tx: txCallback, txType: eEthereumTxType.STAKE_ACTION, gas: this.generateTxPriceEstimation([], txCallback), }; } public async depositMayc(depositInfos: NftDepositInfo[], user: string) { const tokenInfos = depositInfos.map(tokenInfo => calculateAmountByDecimal(tokenInfo), ); const populatedTransaction = this.instance.populateTransaction.depositMAYC(tokenInfos); const txCallback: () => Promise = this.generateTxCallback({ rawTxMethod: async () => populatedTransaction, from: user, }); return { tx: txCallback, txType: eEthereumTxType.STAKE_ACTION, gas: this.generateTxPriceEstimation([], txCallback), }; } public async claimBaycOrMayc( symbol: string, nfts: Array>, user: string, ) { if (['BAYC', 'MAYC'].includes(symbol)) { const method = { BAYC: this.instance.populateTransaction.claimBAYC, MAYC: this.instance.populateTransaction.claimMAYC, }[symbol]; // eslint-disable-next-line const populatedTransaction = method!(nfts, user); const txCallback: () => Promise = this.generateTxCallback({ rawTxMethod: async () => populatedTransaction, from: user, }); return { tx: txCallback, txType: eEthereumTxType.REWARD_ACTION, gas: this.generateTxPriceEstimation([], txCallback), }; } throw new Error(`Unsupported token: ${symbol}`); } public async claimBakc( baycPairs: ApeCoinStaking.PairNftStruct[], maycPairs: ApeCoinStaking.PairNftStruct[], user: string, ) { const populatedTransaction = this.instance.populateTransaction.claimBAKC( baycPairs, maycPairs, user, ); const txCallback: () => Promise = this.generateTxCallback({ rawTxMethod: async () => populatedTransaction, from: user, }); return { tx: txCallback, txType: eEthereumTxType.REWARD_ACTION, gas: this.generateTxPriceEstimation([], txCallback), }; } public async withdrawBaycOrMayc( symbol: string, tokenInfos: ApeCoinStaking.SingleNftStruct[], user: string, ) { if (['BAYC', 'MAYC'].includes(symbol)) { const withdrawTokens = tokenInfos.map(tokenInfo => calculateAmountByDecimal(tokenInfo), ); const method = { BAYC: this.instance.populateTransaction.withdrawBAYC, MAYC: this.instance.populateTransaction.withdrawMAYC, }[symbol]; // eslint-disable-next-line const populatedTransaction = method!(withdrawTokens, user); const txCallback: () => Promise = this.generateTxCallback({ rawTxMethod: async () => populatedTransaction, from: user, }); return { tx: txCallback, txType: eEthereumTxType.STAKE_ACTION, gas: this.generateTxPriceEstimation([], txCallback), }; } throw new Error(`Unsupported token: ${symbol}`); } public async withdrawBakc( baycPairs: BakcWithdrawInfo[], maycPairs: BakcWithdrawInfo[], user: string, ) { const baycWithdrawTokens = baycPairs.map(tokenInfo => calculateAmountByDecimal(tokenInfo), ); const maycWithdrawTokens = maycPairs.map(tokenInfo => calculateAmountByDecimal(tokenInfo), ); const populatedTransaction = this.instance.populateTransaction.withdrawBAKC( baycWithdrawTokens, maycWithdrawTokens, ); const txCallback: () => Promise = this.generateTxCallback({ rawTxMethod: async () => populatedTransaction, from: user, }); return { tx: txCallback, txType: eEthereumTxType.REWARD_ACTION, gas: this.generateTxPriceEstimation([], txCallback), }; } public async depositBAKC( baycPairs: PairDepositInfo[], maycPairs: PairDepositInfo[], user: string, ) { const baycPairsTokenInfos = baycPairs.map(info => calculateAmountByDecimal(info), ); const maycPairsTokenInfos = maycPairs.map(info => calculateAmountByDecimal(info), ); const populatedTransaction = this.instance.populateTransaction.depositBAKC( baycPairsTokenInfos, maycPairsTokenInfos, ); const txCallback: () => Promise = this.generateTxCallback({ rawTxMethod: async () => populatedTransaction, from: user, }); return { tx: txCallback, txType: eEthereumTxType.STAKE_ACTION, gas: this.generateTxPriceEstimation([], txCallback), }; } public async getPlatformStakingOverview(assetsStakedAddrs: string[]) { // Fetches a DashboardStake = [poolId, tokenId, deposited, unclaimed, rewards24Hrs, paired] const [poolsUI, ...stakingData] = await Promise.all([ this.instance.getPoolsUI(), ...assetsStakedAddrs.map(async (addr: string) => this.instance.getAllStakes(addr), ), ]); const poolsData = poolsUI.reduce((PoolsData, item, index) => { const { stakedAmount, currentTimeRange: { rewardsPerHour, capPerPosition }, } = item; const symbol = Object.keys(POOL_ID_MAP).find( key => POOL_ID_MAP[key as keyof PoolsDataProps] === index, ); const apy = stakedAmount.isZero() ? new BigNumberJs(0) : rewardsPerHour.mul(24).mul(365).mul(10000).div(stakedAmount); if (symbol) { PoolsData[symbol as keyof PoolsDataProps] = { paraStakedNFTAmount: 0, paraStakedApeAmount: new BigNumberJs(0), totalStakedApeAmount: new BigNumberJs(stakedAmount.toString()).div( APE_COIN_PRECISION, ), rewardsPerHour: new BigNumberJs(rewardsPerHour.toString()).shiftedBy( APE_COIN_DECIMAL * -1, ), capPerPosition: new BigNumberJs(capPerPosition.toString()).shiftedBy( APE_COIN_DECIMAL * -1, ), apy: new BigNumberJs(apy.toNumber() / 10000), }; } return PoolsData; }, {} as PoolsDataProps); const overview: OverviewProps = { ...poolsData, total: Object.values(poolsData).reduce( (PoolsData, item) => { const { totalStakedApeAmount, rewardsPerHour, capPerPosition, apy } = item; return { ...item, rewardsPerHour: PoolsData.rewardsPerHour.plus(rewardsPerHour), capPerPosition: PoolsData.capPerPosition.plus(capPerPosition), apy: PoolsData.apy.plus(apy), totalStakedApeAmount: PoolsData.totalStakedApeAmount.plus(totalStakedApeAmount), }; }, { paraStakedNFTAmount: 0, paraStakedApeAmount: new BigNumberJs(0), totalStakedApeAmount: new BigNumberJs(0), rewardsPerHour: new BigNumberJs(0), capPerPosition: new BigNumberJs(0), apy: new BigNumberJs(0), }, ), }; uniqWith( stakingData .flat() .filter(each => each.deposited.gt(0) || each.unclaimed.gt(0)), (a, b) => a.poolId === b.poolId && a.tokenId === b.tokenId, ).forEach(each => { if (each.poolId.toNumber() === POOL_ID_MAP.BAYC) { const baycData = overview.BAYC; overview.BAYC = { ...baycData, paraStakedNFTAmount: baycData.paraStakedNFTAmount + 1, paraStakedApeAmount: baycData.paraStakedApeAmount.plus( each.deposited.toString(), ), }; } else if (each.poolId.toNumber() === POOL_ID_MAP.APE) { const apeData = overview.APE; overview.APE = { ...apeData, paraStakedApeAmount: apeData.paraStakedApeAmount.plus( each.deposited.toString(), ), }; } else if (each.poolId.toNumber() === POOL_ID_MAP.MAYC) { const maycData = overview.MAYC; overview.MAYC = { ...maycData, paraStakedNFTAmount: maycData.paraStakedNFTAmount + 1, paraStakedApeAmount: maycData.paraStakedApeAmount.plus( each.deposited.toString(), ), }; } else if (each.poolId.toNumber() === POOL_ID_MAP.BAKC) { const bakcData = overview.BAKC; overview.BAKC = { ...bakcData, paraStakedNFTAmount: bakcData.paraStakedNFTAmount + 1, paraStakedApeAmount: bakcData.paraStakedApeAmount.plus( each.deposited.toString(), ), }; } else { console.warn('Unexpected pool type: ', each.poolId.toString()); } const totalData = overview.total; overview.total = { ...totalData, paraStakedNFTAmount: totalData.paraStakedNFTAmount + 1, paraStakedApeAmount: totalData.paraStakedApeAmount.plus( each.deposited.toString(), ), }; }); overview.APE.paraStakedApeAmount = overview.APE.paraStakedApeAmount.div(APE_COIN_PRECISION); overview.BAYC.paraStakedApeAmount = overview.BAYC.paraStakedApeAmount.div(APE_COIN_PRECISION); overview.MAYC.paraStakedApeAmount = overview.MAYC.paraStakedApeAmount.div(APE_COIN_PRECISION); overview.BAKC.paraStakedApeAmount = overview.BAKC.paraStakedApeAmount.div(APE_COIN_PRECISION); overview.total.paraStakedApeAmount = overview.total.paraStakedApeAmount.div(APE_COIN_PRECISION); return overview; } public async mainToBakc( mainTokens: Array<{ type: 'MAYC' | 'BAYC'; tokenId: number; }>, ) { return Promise.all( mainTokens.map(async ({ type, tokenId }) => this.instance.mainToBakc(POOL_ID_MAP[type], tokenId), ), ); } // nft supplied pos# ape staging modal (single one) // 1. limit => getPoolsUI // 2. staked amt => nftPositions[poolId][tokenId] // 3. pendingRewards // 4. apy // - getBAYC / getMAYC (too much overhead... // - offchain impl _estimate24HourReward => pools().lastRewardsRangeIndex + getPoolsUI.rewardsPerHour + position public async getTokensApeStakingInfo( tokens: Array<{ type: 'BAYC' | 'MAYC' | 'BAKC'; id: string; userAddr: string; }>, ) { const poolsPromise = this.instance.getPoolsUI(); const tokenStakingInfoPromises = tokens.map( async ({ id: tokenId, type: tokenType, userAddr }) => { const poolId = POOL_ID_MAP[tokenType]; const tokenPositionPromise = this.instance.nftPosition(poolId, tokenId); const pendingRewardsPromise = this.instance.pendingRewards( poolId, userAddr, tokenId, ); const { stakedAmount } = await tokenPositionPromise; const pendingRewards = await pendingRewardsPromise; return { stakedAmount: new BigNumberJs(stakedAmount.toString()).div( APE_COIN_PRECISION, ), pendingRewards: new BigNumberJs(pendingRewards.toString()).div( APE_COIN_PRECISION, ), }; }, ); const [pools, tokenStakingInfos] = await Promise.all([ poolsPromise, Promise.all(tokenStakingInfoPromises), ]); return tokens.map(({ type: tokenType }, index) => { const poolId = POOL_ID_MAP[tokenType]; const pool = pools[poolId]; const { capPerPosition: stakeLimit, rewardsPerHour } = pool.currentTimeRange; // apy, the problem is ape staking rewards is not re-compounding here // (stakedAmount / pool.stakedAmount) * rewardsPerHour * 24 * 365 / stakedAmount; const apy = pool.stakedAmount.isZero() ? new BigNumberJs(0) : rewardsPerHour.mul(24).mul(365).mul(10000).div(pool.stakedAmount); return { ...tokenStakingInfos[index], stakeLimit: new BigNumberJs(stakeLimit.toString()).div( APE_COIN_PRECISION, ), apy: new BigNumberJs(apy.toNumber() / 10000), }; }); } // nft supply modal, staking amount (for all bayc/mayc balance) public async getUserApeStakingInfo(userAddr: string) { const pools = await this.instance.getPoolsUI(); return { baycStakes: await this.getFormattedStakes( pools[POOL_ID_MAP.BAYC], async () => this.instance.getBaycStakes(userAddr), ), maycStakes: await this.getFormattedStakes( pools[POOL_ID_MAP.MAYC], async () => this.instance.getMaycStakes(userAddr), ), bakcStakes: await this.getFormattedStakes( pools[POOL_ID_MAP.BAKC], async () => this.instance.getBakcStakes(userAddr), ), }; } public async getSuppliedBakcStakingInfo(nTokenAddr: string) { const pools = await this.instance.getPoolsUI(); const bakcInfo = await this.getFormattedStakes( pools[POOL_ID_MAP.BAKC], async () => this.instance.getBakcStakes(nTokenAddr), ); return bakcInfo; } public async getPoolApy(type: keyof typeof POOL_ID_MAP) { const pools = await this.instance.getPoolsUI(); const { stakedAmount, currentTimeRange: { rewardsPerHour }, } = pools[POOL_ID_MAP[type]]; const apy = stakedAmount.isZero() ? new BigNumberJs(0) : rewardsPerHour.mul(24).mul(365).mul(10000).div(stakedAmount); return new BigNumberJs(apy.toNumber() / 10000); } private getBasicStakeInfo(pool: ApeCoinStaking.PoolUIStructOutput) { const { capPerPosition: stakeLimit, rewardsPerHour } = pool.currentTimeRange; const apy = pool.stakedAmount.isZero() ? new BigNumberJs(0) : rewardsPerHour.mul(24).mul(365).mul(10000).div(pool.stakedAmount); return { apy: new BigNumberJs(apy.toNumber() / 10000), stakeLimit: new BigNumberJs(stakeLimit.toString()).div( APE_COIN_PRECISION, ), }; } private formatStakeInfo(stake: ApeCoinStaking.DashboardStakeStructOutput) { return { stakedAmount: new BigNumberJs(stake.deposited.toString()).div( APE_COIN_PRECISION, ), pendingRewards: new BigNumberJs(stake.unclaimed.toString()).div( APE_COIN_PRECISION, ), tokenId: stake.tokenId.toNumber(), mainTokenId: stake.pair.mainTokenId.toNumber(), mainPoolId: stake.pair.mainTypePoolId.toNumber(), }; } private async getFormattedStakes( pool: ApeCoinStaking.PoolUIStructOutput, getStakes: () => Promise, ) { const basicStakeInfo = this.getBasicStakeInfo(pool); const stakes = await getStakes(); return stakes .map(stake => this.formatStakeInfo(stake)) .map(stake => ({ ...basicStakeInfo, ...stake, })); } }