diff --git a/Strategies/components/DepositModal.tsx b/Strategies/components/DepositModal.tsx index f8c23c3da4..f7511ca245 100644 --- a/Strategies/components/DepositModal.tsx +++ b/Strategies/components/DepositModal.tsx @@ -5,6 +5,9 @@ import MangoDeposit from './MangoDepositComponent' import BigNumber from 'bignumber.js' import { SolendStrategy } from 'Strategies/types/types' import EverlendModalContent from './EverlendModalContent' +import { PsyFiStrategies } from './psyfi' +import { AssetAccount } from '@utils/uiTypes/assets' +import { MangoAccount } from '@blockworks-foundation/mango-client' const DepositModal = ({ onClose, @@ -19,6 +22,19 @@ const DepositModal = ({ createProposalFcn, mangoAccounts, governedTokenAccount, +}: { + onClose: () => void + proposedInvestment: any + handledMint: string + apy: string + protocolName: string + protocolLogoSrc: string + handledTokenName: string + strategyName: string + currentPosition: number + createProposalFcn: any + mangoAccounts: MangoAccount[] + governedTokenAccount: AssetAccount }) => { const currentPositionFtm = new BigNumber( currentPosition.toFixed(0) @@ -58,6 +74,15 @@ const DepositModal = ({ createProposalFcn={createProposalFcn} /> ) : null} + {/* TODO: Add the PsyFi modal */} + {protocolName === 'PsyFi' ? ( + + ) : null} ) } diff --git a/Strategies/components/psyfi/Deposit.tsx b/Strategies/components/psyfi/Deposit.tsx new file mode 100644 index 0000000000..bf8506cf38 --- /dev/null +++ b/Strategies/components/psyfi/Deposit.tsx @@ -0,0 +1,426 @@ +import { getAssociatedTokenAddress } from '@blockworks-foundation/mango-v4' +import AdditionalProposalOptions from '@components/AdditionalProposalOptions' +import Button, { LinkButton } from '@components/Button' +import Input from '@components/inputs/Input' +import Select from '@components/inputs/Select' +import Loading from '@components/Loading' +import Tooltip from '@components/Tooltip' +import useGovernanceAssets from '@hooks/useGovernanceAssets' +import useQueryContext from '@hooks/useQueryContext' +import useRealm from '@hooks/useRealm' +import { getProgramVersionForRealm } from '@models/registry/api' +import { BN } from '@project-serum/anchor' +import { RpcContext } from '@solana/spl-governance' +import { PublicKey } from '@solana/web3.js' +import { + getMintDecimalAmount, + getMintDecimalAmountFromNatural, + getMintMinAmountAsDecimal, + getMintNaturalAmountFromDecimalAsBN, +} from '@tools/sdk/units' +import { precision } from '@utils/formatting' +import tokenService from '@utils/services/token' +import { AssetAccount } from '@utils/uiTypes/assets' +import BigNumber from 'bignumber.js' +import { useRouter } from 'next/router' +import { pdas } from 'psyfi-euros-test' +import React, { useCallback, useEffect, useState } from 'react' +import useVotePluginsClientStore from 'stores/useVotePluginsClientStore' +import useWalletStore from 'stores/useWalletStore' +import { + Action, + CreatePsyFiStrategy, + DepositReceipt, + PsyFiStrategyForm, + PsyFiStrategyInfo, +} from 'Strategies/protocols/psyfi/types' +import { PsyFiStrategy } from 'Strategies/types/types' +import { usePsyFiProgram } from './hooks/usePsyFiProgram' +import { notify } from '@utils/notifications' + +const SOL_BUFFER = 0.02 + +export const Deposit: React.FC<{ + proposedInvestment: PsyFiStrategy + governedTokenAccount: AssetAccount + handledMint: string + createProposalFcn: CreatePsyFiStrategy +}> = ({ + createProposalFcn, + handledMint, + proposedInvestment, + governedTokenAccount, +}) => { + const router = useRouter() + const { fmtUrlWithCluster } = useQueryContext() + const { + realmInfo, + realm, + ownVoterWeight, + mint, + councilMint, + config, + symbol, + } = useRealm() + const { + canUseTransferInstruction, + governedTokenAccountsWithoutNfts, + } = useGovernanceAssets() + const client = useVotePluginsClientStore( + (s) => s.state.currentRealmVotingClient + ) + const connection = useWalletStore((s) => s.connection) + const wallet = useWalletStore((s) => s.current) + const [ownedStrategyTokenAccount, setOwnedStrategyTokenAccount] = useState< + AssetAccount | undefined + >() + const [underlyingDeposited, setUnderlyingDeposited] = useState< + number | undefined + >() + const [depositReceipt, setDepositReceipt] = useState< + DepositReceipt | undefined + >() + const [depositReceiptPubkey, setDepositReceiptPubkey] = useState() + const [isDepositing, setIsDepositing] = useState(false) + const [voteByCouncil, setVoteByCouncil] = useState(false) + const [form, setForm] = useState({ + strategy: proposedInvestment, + title: '', + description: '', + }) + const [formErrors, setFormErrors] = useState({}) + const psyFiProgram = usePsyFiProgram() + + const handleSetForm = useCallback( + ({ propertyName, value }) => { + setFormErrors({}) + setForm({ ...form, [propertyName]: value }) + }, + [setForm, setFormErrors] + ) + const tokenInfo = tokenService.getTokenInfo(handledMint) + const tokenSymbol = tokenService.getTokenInfo( + governedTokenAccount.extensions.mint!.publicKey.toBase58() + )?.symbol + const mintInfo = governedTokenAccount.extensions?.mint?.account + const treasuryAmount = new BN( + governedTokenAccount.isSol + ? governedTokenAccount.extensions.amount!.toNumber() + : governedTokenAccount.extensions.token!.account.amount + ) + const mintMinAmount = mintInfo ? getMintMinAmountAsDecimal(mintInfo) : 1 + let maxAmount = mintInfo + ? getMintDecimalAmount(mintInfo, treasuryAmount) + : new BigNumber(0) + if (governedTokenAccount.isSol) { + maxAmount = maxAmount.minus(SOL_BUFFER) + } + const maxAmountFtm = maxAmount.toNumber().toFixed(4) + const currentPrecision = precision(mintMinAmount) + + const validateAmountOnBlur = useCallback(() => { + handleSetForm({ + propertyName: 'amount', + value: parseFloat( + Math.max( + Number(mintMinAmount), + Math.min(Number(Number.MAX_SAFE_INTEGER), Number(form.amount)) + ).toFixed(currentPrecision) + ), + }) + }, [handleSetForm, mintMinAmount, form.amount, currentPrecision]) + + useEffect(() => { + ;(async () => { + // TODO: Dry this up with the other areas of the code that use the owner + const owner = governedTokenAccount.isSol + ? governedTokenAccount!.pubkey + : governedTokenAccount!.extensions!.token!.account.owner + // Derive the deposit receipt address + const [address] = await pdas.deriveDepositReceipt( + // @ts-ignore: Anchor version difference + psyFiProgram, + owner, + form.strategy.vaultAccounts.pubkey, + form.strategy.vaultInfo.status.currentEpoch + ) + setDepositReceiptPubkey(address) + + // @ts-ignore: More anchor type stuff + const currentDepositReceipt = ((await psyFiProgram.account.depositReceipt.fetchNullable( + address + )) as unknown) as DepositReceipt | undefined + setDepositReceipt(currentDepositReceipt) + })() + }, [form.strategy, psyFiProgram]) + + // Find the owned strategy token account, if one exists + useEffect(() => { + ;(async () => { + const owner = governedTokenAccount.isSol + ? governedTokenAccount!.pubkey + : governedTokenAccount!.extensions!.token!.account.owner + const tokenAddress = await getAssociatedTokenAddress( + form.strategy.vaultAccounts.lpTokenMint, + owner, + true + ) + + // Cross ref with this governances' token accounts and pull holdings + // NOTE: This knowingly restricts to ATAs. + const existingStrategyTokenAccount = governedTokenAccountsWithoutNfts.find( + (x) => x.pubkey.equals(tokenAddress) + ) + setOwnedStrategyTokenAccount(existingStrategyTokenAccount) + if ( + existingStrategyTokenAccount && + existingStrategyTokenAccount.extensions.amount!.gtn(0) + ) { + // Get the token supply + const strategyTokenSupply = existingStrategyTokenAccount.extensions + .mint!.account.supply + const ownedAmount = existingStrategyTokenAccount.extensions.amount! + // Get the amount of underlying represented by the vault + const underlyingBn = getMintNaturalAmountFromDecimalAsBN( + form.strategy.liquidity, + governedTokenAccount.extensions.mint!.account.decimals + ) + // Calculate ownership from ratio + const amountOwned = underlyingBn + .mul(ownedAmount) + .div(strategyTokenSupply) + const underlyingOwned = getMintDecimalAmountFromNatural( + governedTokenAccount.extensions.mint!.account, + amountOwned + ).toNumber() + setUnderlyingDeposited(underlyingOwned) + } + })() + }, [form.strategy, governedTokenAccount, governedTokenAccountsWithoutNfts]) + + const handleDeposit = useCallback(async () => { + try { + setIsDepositing(true) + const rpcContext = new RpcContext( + new PublicKey(realm!.owner.toString()), + getProgramVersionForRealm(realmInfo!), + wallet!, + connection.current, + connection.endpoint + ) + const ownTokenRecord = ownVoterWeight.getTokenRecordToCreateProposal( + governedTokenAccount!.governance!.account.config, + voteByCouncil + ) + const defaultProposalMint = voteByCouncil + ? realm?.account.config.councilMint + : !mint?.supply.isZero() || + config?.account.communityTokenConfig.maxVoterWeightAddin + ? realm!.account.communityMint + : !councilMint?.supply.isZero() + ? realm!.account.config.councilMint + : undefined + + if (!depositReceiptPubkey) { + // This should be unreachable + throw new Error('Deposit receipt key must be derived first') + } + const strategyInfo: PsyFiStrategyInfo = { + depositReceipt, + depositReceiptPubkey, + ownedStrategyTokenAccount: ownedStrategyTokenAccount, + } + const proposalAddress = await createProposalFcn( + rpcContext, + { + ...form, + action: Action.Deposit, + bnAmount: getMintNaturalAmountFromDecimalAsBN( + form.amount as number, + governedTokenAccount.extensions.mint!.account.decimals + ), + }, + psyFiProgram, + strategyInfo, + realm!, + governedTokenAccount!, + ownTokenRecord, + defaultProposalMint!, + governedTokenAccount!.governance!.account!.proposalCount, + false, + connection, + client + ) + const url = fmtUrlWithCluster( + `/dao/${symbol}/proposal/${proposalAddress}` + ) + router.push(url) + setIsDepositing(false) + } catch (error) { + console.log('ERROR', error) + notify({ type: 'error', message: `Error ${error}` }) + setIsDepositing(false) + } + }, [ + client, + config, + connection, + councilMint, + depositReceipt, + depositReceiptPubkey, + fmtUrlWithCluster, + form, + governedTokenAccount, + mint, + ownedStrategyTokenAccount, + ownVoterWeight, + psyFiProgram, + realm, + realmInfo, + router, + symbol, + voteByCouncil, + wallet, + ]) + + useEffect(() => { + if (form.title === '' || form.description === '') { + setForm({ + ...form, + title: + form.title === '' + ? `Deposit ${tokenSymbol} into ${form.strategy.strategyName} strategy` + : form.title, + description: + form.description === '' + ? `Deposit ${tokenSymbol} into ${form.strategy.strategyName} strategy` + : form.description, + }) + } + }, [form, setForm, tokenSymbol]) + + return ( + <> + +
+ + Read about the risks + +
+ +
+ Amount +
+ Bal: {maxAmountFtm} + + handleSetForm({ + propertyName: 'amount', + value: maxAmount.toNumber(), + }) + } + className="font-bold ml-2 text-primary-light" + > + Max + +
+
+ + handleSetForm({ propertyName: 'amount', value: e.target.value }) + } + step={mintMinAmount} + onBlur={validateAmountOnBlur} + /> + + handleSetForm({ + value: evt.target.value, + propertyName: 'title', + }) + } + setDescription={(evt) => + handleSetForm({ + value: evt.target.value, + propertyName: 'description', + }) + } + voteByCouncil={voteByCouncil} + setVoteByCouncil={setVoteByCouncil} + /> + +
+
+ Pending Deposits + + {depositReceipt + ? getMintDecimalAmountFromNatural( + governedTokenAccount.extensions.mint!.account, + depositReceipt.depositAmount + ).toNumber() + : 0}{' '} + {tokenInfo?.symbol} + +
+
+ Current Deposit + + {underlyingDeposited?.toLocaleString() || 0}{' '} + {tokenInfo?.symbol} + +
+
+ + + ) +} diff --git a/Strategies/components/psyfi/PsyFiStrategies.tsx b/Strategies/components/psyfi/PsyFiStrategies.tsx new file mode 100644 index 0000000000..15eed767f0 --- /dev/null +++ b/Strategies/components/psyfi/PsyFiStrategies.tsx @@ -0,0 +1,31 @@ +import { AssetAccount } from '@utils/uiTypes/assets' +import { CreatePsyFiStrategy } from 'Strategies/protocols/psyfi/types' +import { PsyFiStrategy } from 'Strategies/types/types' +import { Deposit } from './Deposit' + +export const PsyFiStrategies: React.FC<{ + proposedInvestment: PsyFiStrategy + governedTokenAccount: AssetAccount + handledMint: string + createProposalFcn: CreatePsyFiStrategy +}> = ({ + createProposalFcn, + handledMint, + proposedInvestment, + governedTokenAccount, +}) => { + return ( +
+ {/* + TODO: Add a higher level selector that determines the action (Deposit, + Withdraw, Cancel pending deposit, etc) and separate out the action components. + */} + +
+ ) +} diff --git a/Strategies/components/psyfi/hooks/usePsyFiProgram.ts b/Strategies/components/psyfi/hooks/usePsyFiProgram.ts new file mode 100644 index 0000000000..dd9bcdad3a --- /dev/null +++ b/Strategies/components/psyfi/hooks/usePsyFiProgram.ts @@ -0,0 +1,24 @@ +import { AnchorProvider, Program } from '@project-serum/anchor' +import { PsyFiEuros, PsyFiIdl } from 'psyfi-euros-test' +import { useMemo } from 'react' +import useWalletStore from 'stores/useWalletStore' +import { MAINNET_PROGRAM_KEYS } from '../programIds' + +export const usePsyFiProgram = () => { + const connection = useWalletStore((s) => s.connection) + const wallet = useWalletStore((s) => s.current) + + // construct the PsyFi program. This could be pulled into a hook + return useMemo(() => { + const anchorProvider = new AnchorProvider( + connection.current, + wallet as any, + {} + ) + return new Program( + PsyFiIdl, + MAINNET_PROGRAM_KEYS.PSYFI_V2, + anchorProvider + ) + }, [connection.current, wallet]) +} diff --git a/Strategies/components/psyfi/index.ts b/Strategies/components/psyfi/index.ts new file mode 100644 index 0000000000..d17ff3be06 --- /dev/null +++ b/Strategies/components/psyfi/index.ts @@ -0,0 +1 @@ +export * from './PsyFiStrategies' diff --git a/Strategies/components/psyfi/pdas.ts b/Strategies/components/psyfi/pdas.ts new file mode 100644 index 0000000000..e1672066a6 --- /dev/null +++ b/Strategies/components/psyfi/pdas.ts @@ -0,0 +1,14 @@ +import { PublicKey } from '@solana/web3.js' + +export const deriveVaultCollateralAccount = async ( + programKey: PublicKey, + vaultAccount: PublicKey +) => { + return await PublicKey.findProgramAddress( + [ + new PublicKey(vaultAccount).toBuffer(), + Buffer.from('VaultCollateralAccount'), + ], + programKey + ) +} diff --git a/Strategies/components/psyfi/programIds.ts b/Strategies/components/psyfi/programIds.ts new file mode 100644 index 0000000000..da4718135e --- /dev/null +++ b/Strategies/components/psyfi/programIds.ts @@ -0,0 +1,14 @@ +import { PublicKey } from '@solana/web3.js' +export const MAINNET_PROGRAM_KEYS = { + PSYFI_V2: new PublicKey('PSYFiYqguvMXwpDooGdYV6mju92YEbFobbvW617VNcq'), + PSYSTAKE: new PublicKey('pSystkitWgLkzprdAvraP8DSBiXwee715wiSXGJe8yr'), +} + +export const DEVNET_PROGRAM_KEYS = { + PSYFI_V2: new PublicKey('95q3X9ADJv5hWt93oSaPqABPnP1rqfmjgrnto9v83LPK'), + PSYSTAKE: new PublicKey('5LrZkBFgDkFiKEePeT2N9VuKfd2k8Rrad9PG6mKGbCRk'), +} + +export const EURO_PRIMITIVE_PROGRAM_ID = new PublicKey( + 'FASQhaZQT53W9eT9wWnPoBFw8xzZDey9TbMmJj6jCQTs' +) diff --git a/Strategies/protocols/psyfi/actions/deposit.ts b/Strategies/protocols/psyfi/actions/deposit.ts new file mode 100644 index 0000000000..efabf5d291 --- /dev/null +++ b/Strategies/protocols/psyfi/actions/deposit.ts @@ -0,0 +1,181 @@ +import { Program } from '@project-serum/anchor' +import { + RpcContext, + serializeInstructionToBase64, +} from '@solana/spl-governance' +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + NATIVE_MINT, + Token, + TOKEN_PROGRAM_ID, +} from '@solana/spl-token' +import { + PublicKey, + SystemProgram, + TransactionInstruction, + LAMPORTS_PER_SOL, +} from '@solana/web3.js' +import { AssetAccount } from '@utils/uiTypes/assets' +import { UiInstruction } from '@utils/uiTypes/proposalCreationTypes' +import { InstructionDataWithHoldUpTime } from 'actions/createProposal' +import { instructions as psyFiInstructions, PsyFiEuros } from 'psyfi-euros-test' +import { PsyFiActionForm, PsyFiStrategyInfo } from '../types' +import { syncNative } from '@solendprotocol/solend-sdk' + +export const deposit = async ( + rpcContext: RpcContext, + treasuryAssetAccount: AssetAccount, + psyFiProgram: Program, + psyFiStrategyInfo: PsyFiStrategyInfo, + form: PsyFiActionForm, + owner: PublicKey, + transferAddress: PublicKey +) => { + const instructions: InstructionDataWithHoldUpTime[] = [] + + let vaultOwnershipAccount: PublicKey | undefined = + psyFiStrategyInfo.ownedStrategyTokenAccount?.pubkey + const prerequisiteInstructions: TransactionInstruction[] = [] + + let coreDepositIx: TransactionInstruction + const serializedTransferToReceiptIxs: string[] = [] + + // If the lp token account does not exist, add it to the pre-requisite instructions + if (!vaultOwnershipAccount) { + const address = await Token.getAssociatedTokenAddress( + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + form.strategy.vaultAccounts.lpTokenMint, + owner, + true + ) + const createAtaIx = Token.createAssociatedTokenAccountInstruction( + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + form.strategy.vaultAccounts.lpTokenMint, + address, + owner, + rpcContext.walletPubkey + ) + prerequisiteInstructions.push(createAtaIx) + vaultOwnershipAccount = address + } + + let poolMintATA + + // If the pool mint associated token account does not exist, add it to the pre-requisite instructions + + if (form.amount && treasuryAssetAccount.isSol) { + poolMintATA = await Token.getAssociatedTokenAddress( + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + NATIVE_MINT, + owner, + true + ) + + if ( + (await psyFiProgram.provider.connection.getAccountInfo(poolMintATA)) === + null + ) { + prerequisiteInstructions.push( + Token.createAssociatedTokenAccountInstruction( + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + NATIVE_MINT, + poolMintATA, + owner, + rpcContext.walletPubkey + ) + ) + } + + const wsolTransferIx = SystemProgram.transfer({ + fromPubkey: owner, + toPubkey: poolMintATA, + lamports: form.amount * LAMPORTS_PER_SOL, + }) + + serializedTransferToReceiptIxs.push( + serializeInstructionToBase64(wsolTransferIx) + ) + serializedTransferToReceiptIxs.push( + serializeInstructionToBase64(syncNative(poolMintATA)) + ) + } + + // Check if the vault requires a deposit receipt + if (form.strategy.vaultInfo.status.optionsActive) { + if (!psyFiStrategyInfo.depositReceipt) { + // Add init deposit receipt instruction + const initReceiptIx = await psyFiInstructions.initializeDepositReceiptInstruction( + // @ts-ignore: Anchor version differences. + psyFiProgram, + form.strategy.vaultInfo.status.currentEpoch, + owner, + form.strategy.vaultAccounts.pubkey + ) + const uiInstruction: UiInstruction = { + governance: treasuryAssetAccount.governance, + serializedInstruction: serializeInstructionToBase64(initReceiptIx), + prerequisiteInstructions: [], + chunkSplitByDefault: true, + isValid: true, + customHoldUpTime: + treasuryAssetAccount.governance.account.config + .minInstructionHoldUpTime, + } + const initReceiptFullPropIx = new InstructionDataWithHoldUpTime({ + instruction: uiInstruction, + }) + instructions.push(initReceiptFullPropIx) + } + + // Create transfer to deposit receipt instruction + coreDepositIx = await psyFiInstructions.transferToDepositReceiptInstruction( + // @ts-ignore: Anchor version differences. + psyFiProgram, + form.bnAmount, + form.strategy.vaultInfo.status.currentEpoch, + owner, + form.strategy.vaultAccounts.pubkey, + treasuryAssetAccount.isSol ? poolMintATA : transferAddress + ) + + serializedTransferToReceiptIxs.push( + serializeInstructionToBase64(coreDepositIx) + ) + } else { + // Create the actual deposit instruction + coreDepositIx = await psyFiInstructions.depositInstruction( + // @ts-ignore: Anchor version differences. + psyFiProgram, + form.bnAmount, + owner, + form.strategy.vaultAccounts.pubkey, + transferAddress, + vaultOwnershipAccount + ) + serializedTransferToReceiptIxs.push( + serializeInstructionToBase64(coreDepositIx) + ) + } + + // Create the InstructionDataWithHoldUpTime + const uiInstruction: UiInstruction = { + governance: treasuryAssetAccount.governance, + serializedInstruction: serializedTransferToReceiptIxs[0], + additionalSerializedInstructions: + serializedTransferToReceiptIxs.slice(1) || [], + prerequisiteInstructions, + chunkSplitByDefault: true, + isValid: true, + customHoldUpTime: + treasuryAssetAccount.governance.account.config.minInstructionHoldUpTime, + } + const fullPropInstruction = new InstructionDataWithHoldUpTime({ + instruction: uiInstruction, + }) + instructions.push(fullPropInstruction) + return instructions +} diff --git a/Strategies/protocols/psyfi/index.ts b/Strategies/protocols/psyfi/index.ts new file mode 100644 index 0000000000..02df0839bf --- /dev/null +++ b/Strategies/protocols/psyfi/index.ts @@ -0,0 +1,202 @@ +import { ConnectionContext } from '@utils/connection' +import { PsyFiStrategy } from 'Strategies/types/types' +import axios from 'axios' + +import { + Action, + CreatePsyFiStrategy, + PsyFiActionForm, + PsyFiStrategyInfo, + Strategy, + TokenGroupedVaults, + VaultInfo, +} from './types' +import tokenService from '@utils/services/token' +import { + ProgramAccount, + Realm, + RpcContext, + TokenOwnerRecord, +} from '@solana/spl-governance' +import { Program } from '@project-serum/anchor' +import { AssetAccount } from '@utils/uiTypes/assets' +import { PublicKey } from '@solana/web3.js' +import { VotingClient } from '@utils/uiTypes/VotePlugin' +import { + createProposal, + InstructionDataWithHoldUpTime, +} from 'actions/createProposal' +import { deriveVaultCollateralAccount } from 'Strategies/components/psyfi/pdas' +import { MAINNET_PROGRAM_KEYS } from 'Strategies/components/psyfi/programIds' + +import { PsyFiEuros } from 'psyfi-euros-test' +import { deposit } from './actions/deposit' + +export const getVaultInfos = async (): Promise => { + const res = await axios.get( + `https://us-central1-psyfi-api.cloudfunctions.net/vaults?env=mainnet` + ) + const vaultInfos = Object.values(res.data.vaults as any) as VaultInfo[] + return vaultInfos +} + +const handleVaultAction: CreatePsyFiStrategy = async ( + rpcContext: RpcContext, + form: PsyFiActionForm, + psyFiProgram: Program, + psyFiStrategyInfo: PsyFiStrategyInfo, + realm: ProgramAccount, + treasuryAssetAccount: AssetAccount, + tokenOwnerRecord: ProgramAccount, + governingTokenMint: PublicKey, + proposalIndex: number, + isDraft: boolean, + connection: ConnectionContext, + client?: VotingClient +) => { + const owner = treasuryAssetAccount.isSol + ? treasuryAssetAccount!.pubkey + : treasuryAssetAccount!.extensions!.token!.account.owner + const transferAddress = treasuryAssetAccount.extensions.transferAddress! + + let instructions: InstructionDataWithHoldUpTime[] = [] + + if (form.action === Action.Deposit) { + instructions = await deposit( + rpcContext, + treasuryAssetAccount, + psyFiProgram, + psyFiStrategyInfo, + form, + owner, + transferAddress + ) + } + + console.log('*** instructions', instructions) + + const proposalAddress = await createProposal( + rpcContext, + realm, + treasuryAssetAccount.governance!.pubkey, + tokenOwnerRecord, + form.title, + form.description, + governingTokenMint, + proposalIndex, + instructions, + isDraft, + client + ) + return proposalAddress +} + +export const convertVaultInfoToStrategy = async ( + vaultInfo: VaultInfo, + otherStrategies: PsyFiStrategy[] | undefined +): Promise => { + let strategyName = '' + if (vaultInfo.strategyType === Strategy.Call) { + strategyName = vaultInfo.name + } else if (vaultInfo.strategyType === Strategy.Put) { + strategyName = vaultInfo.name + } + const handledMint = vaultInfo.accounts.collateralAssetMint + const tokenInfo = await tokenService.getTokenInfo(handledMint) + if (!tokenInfo) { + return + } + const apyPercentage = vaultInfo.apy.movingAverageApy.apyAfterFees.toFixed(2) + const vaultPubkey = new PublicKey(vaultInfo.accounts.vaultAddress) + const [collateralAccountKey] = await deriveVaultCollateralAccount( + MAINNET_PROGRAM_KEYS.PSYFI_V2, + vaultPubkey + ) + const strategy: PsyFiStrategy = { + liquidity: vaultInfo.deposits.current, + protocolSymbol: 'PSY', + apy: `Estimated ${apyPercentage}%`, + apyHeader: `Projected Yield`, + protocolName: 'PsyFi', + handledMint, + handledTokenSymbol: tokenInfo.symbol, + handledTokenImgSrc: tokenInfo.logoURI || '', + protocolLogoSrc: + 'https://user-images.githubusercontent.com/32071703/149460918-3694084f-2a37-4c95-93d3-b5aaf078d444.png', + strategyName, + strategyDescription: 'Description', + isGenericItem: false, + createProposalFcn: handleVaultAction, + otherStrategies: otherStrategies ?? [], + vaultInfo: vaultInfo, + vaultAccounts: { + pubkey: vaultPubkey, + lpTokenMint: new PublicKey(vaultInfo.accounts.vaultOwnershipTokenMint), + collateralAccountKey, + }, + } + return strategy +} + +export const getPsyFiStrategies = async (): Promise => { + const vaultInfos = await getVaultInfos() + + // group strategies by token + const groupedVaults = groupVaultsByToken(vaultInfos) + + // Change how strategies are created using a custom type that has all token strategies + // as additionalStrategies. + return psyFiVestingStrategies(groupedVaults) +} + +const psyFiVestingStrategies = async ( + groupedVaults: TokenGroupedVaults +): Promise => { + const res = await Promise.all( + Object.keys(groupedVaults).map(async (collateralTokenAddress) => { + const strategies = groupedVaults[collateralTokenAddress] + const topVault = strategies[0] + if (!topVault) { + // This should be unreachable + throw new Error(`No vault found for ${collateralTokenAddress}`) + } + const otherStrategies = await Promise.all( + strategies.map( + async (x) => await convertVaultInfoToStrategy(x, undefined) + ) + ) + return convertVaultInfoToStrategy( + topVault, + // @ts-ignore: + otherStrategies.filter((x) => !!x) + ) + }) + ) + + // @ts-ignore + return res.filter((x) => !!x) +} + +/** + * Given an array for VaultInfos, group by collateral token and sort the groups by APY + */ +const groupVaultsByToken = (vaultInfos: VaultInfo[]) => { + const res: TokenGroupedVaults = {} + vaultInfos.forEach((vaultInfo) => { + if (res[vaultInfo.accounts.collateralAssetMint]) { + const strategies = res[vaultInfo.accounts.collateralAssetMint] + strategies.push(vaultInfo) + strategies.sort((a, b) => { + return ( + b.apy.movingAverageApy.apyAfterFees - + a.apy.movingAverageApy.apyAfterFees + ) + }) + res[vaultInfo.accounts.collateralAssetMint] = strategies + } else { + res[vaultInfo.accounts.collateralAssetMint] = [vaultInfo] + } + }) + + return res +} diff --git a/Strategies/protocols/psyfi/types.ts b/Strategies/protocols/psyfi/types.ts new file mode 100644 index 0000000000..6d8c2361eb --- /dev/null +++ b/Strategies/protocols/psyfi/types.ts @@ -0,0 +1,174 @@ +import { BN, Program } from '@project-serum/anchor' +import { + ProgramAccount, + Realm, + RpcContext, + TokenOwnerRecord, +} from '@solana/spl-governance' +import { PublicKey } from '@solana/web3.js' +import { ConnectionContext } from '@utils/connection' +import { AssetAccount } from '@utils/uiTypes/assets' +import { VotingClient } from '@utils/uiTypes/VotePlugin' +import { PsyFiEuros } from 'psyfi-euros-test' +import { PsyFiStrategy } from 'Strategies/types/types' + +export type PsyFiStrategyForm = { + strategy: PsyFiStrategy + title: string + description: string + amount?: number +} + +export type PsyFiActionForm = PsyFiStrategyForm & { + action: Action + bnAmount: BN +} + +export type CreatePsyFiStrategy = ( + rpcContext: RpcContext, + form: { + title: string + description: string + action: Action + bnAmount: BN + strategy: PsyFiStrategy + }, + psyFiProgram: Program, + psyFiStrategyInfo: PsyFiStrategyInfo, + realm: ProgramAccount, + treasuaryAccount: AssetAccount, + tokenOwnerRecord: ProgramAccount, + governingTokenMint: PublicKey, + proposalIndex: number, + isDraft: boolean, + connection: ConnectionContext, + client?: VotingClient +) => Promise + +export type PsyFiStrategyInfo = { + depositReceipt: DepositReceipt | undefined + depositReceiptPubkey: PublicKey + ownedStrategyTokenAccount: AssetAccount | undefined +} + +export type DepositReceipt = { + vaultAccount: PublicKey + epochHistory: PublicKey + receiptOwner: PublicKey // Owner of funds deposited. + depositAmount: BN // In collateral asset + bump: number + lockupPeriod: number // Lockup period if vault tokens are staked. + forStaking: boolean // If vault tokens should be staked. + stakingRecord: PublicKey // Destination of vault tokens, if staking. +} + +export enum Strategy { + Call = 0, + Put = 1, +} + +export type StrategyInfo = { + currentDeposits: number +} + +export enum Action { + Deposit = 0, + Withdraw = 1, +} + +export enum VaultVisibility { + Development, + Staging, + Production, +} + +export type PoolReward = { + metadata?: { + rewardPoolApr?: number[] + rewardInUsdPerYearPerRewardUnit?: number + usdValuePerRewardToken?: number + } + tokenSymbol: string + rewardPoolKey: string + rewardTokensPerWeek: number + rewardMintAddress: string + multiplier: number + poolId: number +} + +export type VaultInfo = { + id: string + name: string + version: number + strategyType: Strategy + visibility: VaultVisibility + accounts: { + vaultAddress: string + collateralAssetMint: string + vaultOwnershipTokenMint: string + optionsUnderlyingMint: string + pythPriceOracle: string + feeTokenAccount: string + } + deposits: { + current: number + max: number + } + fees: { + performance: number + withdrawal: number + } + status: { + currentEpoch: number + optionsActive: boolean + nextEpochStartTime: number + nextOptionMintTime: number + isDeprecated: boolean + } + stakingProviderUrl?: string + selectedStrike?: number + apy: { + currentEpochApy: number + stakingApy: number + movingAverageApy: { + apyBeforeFees: number + apyAfterFees: number + epochsCounted: number + averageEpochYield: number // Value before fees. + } + weightedApy: { + targetDelta: number + averageHistoricalLoss: number + epochsCounted: number + averageSaleYield: number + apyBeforeFees: number + apyAfterFees: number + } + } + vaultHistory: VaultHistory[] + valuePerVaultToken: number + staking?: { + metadata?: { + usdValuePerVaultToken?: number + } + stakePoolKey: string + stakingApr: number[] + poolRewards: PoolReward[] + } +} + +export type VaultHistory = { + saleAmount: number + saleYield: number + priceAtExpiry: number + endingValuePerVaultToken: number + strikePrice: number + overallYield: number + percentageLossOnCollateral: number + epoch: number + optionMinted: string + startDate: number + epochHistoryKey: string +} + +export type TokenGroupedVaults = Record diff --git a/Strategies/store/useStrategiesStore.tsx b/Strategies/store/useStrategiesStore.tsx index 3e39e96c22..28aa7a68b8 100644 --- a/Strategies/store/useStrategiesStore.tsx +++ b/Strategies/store/useStrategiesStore.tsx @@ -1,6 +1,7 @@ import { ConnectionContext } from '@utils/connection' import { notify } from '@utils/notifications' import { tvl } from 'Strategies/protocols/mango/tools' +import { getPsyFiStrategies } from 'Strategies/protocols/psyfi' import { getSolendStrategies } from 'Strategies/protocols/solend' import { TreasuryStrategy } from 'Strategies/types/types' import create, { State } from 'zustand' @@ -20,12 +21,20 @@ const useStrategiesStore = create((set, _get) => ({ s.strategiesLoading = true }) try { - const mango = await tvl(Date.now() / 1000, connection) - const solend = await getSolendStrategies() - const everlend = await getEverlendStrategies(connection) + const [mango, solend, everlend, psyfi] = await Promise.all([ + tvl(Date.now() / 1000, connection), + getSolendStrategies(), + getEverlendStrategies(connection), + getPsyFiStrategies(), + ]) //add fetch functions for your protocol in promise.all - const strategies: TreasuryStrategy[] = [...solend, ...mango, ...everlend] + const strategies: TreasuryStrategy[] = [ + ...solend, + ...mango, + ...everlend, + ...psyfi, + ] set((s) => { s.strategies = strategies diff --git a/Strategies/types/types.ts b/Strategies/types/types.ts index 51b1f7c0a1..397f639a1c 100644 --- a/Strategies/types/types.ts +++ b/Strategies/types/types.ts @@ -13,12 +13,14 @@ import { CreateSolendStrategyParams, SolendSubStrategy, } from 'Strategies/protocols/solend' +import { VaultInfo } from 'Strategies/protocols/psyfi/types' export interface TreasuryStrategy { //liquidity in $ liquidity: number protocolSymbol: string apy: string + apyHeader?: string protocolName: string strategySubtext?: string handledMint: string @@ -44,6 +46,16 @@ export type SolendStrategy = TreasuryStrategy & { createProposalFcn: CreateSolendStrategyParams } +export type PsyFiStrategy = TreasuryStrategy & { + vaultAccounts: { + pubkey: PublicKey + lpTokenMint: PublicKey + collateralAccountKey: PublicKey + } + vaultInfo: VaultInfo + otherStrategies: Array +} + export type EverlendStrategy = TreasuryStrategy & { poolMint: string decimals: number diff --git a/actions/executeInstructions.ts b/actions/executeInstructions.ts index e0ad5b0528..c9a22a1e3e 100644 --- a/actions/executeInstructions.ts +++ b/actions/executeInstructions.ts @@ -53,7 +53,6 @@ export const executeInstructions = async ( const transaction = new Transaction() transaction.add(...instructions) - const signedTransaction = await signTransaction({ transaction, wallet, diff --git a/components/AdditionalProposalOptions.tsx b/components/AdditionalProposalOptions.tsx index 250ead7dec..7e667bd7fa 100644 --- a/components/AdditionalProposalOptions.tsx +++ b/components/AdditionalProposalOptions.tsx @@ -6,16 +6,7 @@ import { LinkButton } from './Button' import Input from './inputs/Input' import Textarea from './inputs/Textarea' -const AdditionalProposalOptions = ({ - title, - description, - setTitle, - setDescription, - defaultTitle, - defaultDescription, - voteByCouncil, - setVoteByCouncil, -}: { +const AdditionalProposalOptions: React.FC<{ title: string description: string setTitle: (evt) => void @@ -24,6 +15,15 @@ const AdditionalProposalOptions = ({ defaultDescription?: string voteByCouncil: boolean setVoteByCouncil: (val) => void +}> = ({ + title, + description, + setTitle, + setDescription, + defaultTitle, + defaultDescription, + voteByCouncil, + setVoteByCouncil, }) => { const [showOptions, setShowOptions] = useState(false) const { canChooseWhoVote } = useRealm() diff --git a/components/TreasuryAccount/AccountOverview.tsx b/components/TreasuryAccount/AccountOverview.tsx index d2ccdea0b7..8372a4422d 100644 --- a/components/TreasuryAccount/AccountOverview.tsx +++ b/components/TreasuryAccount/AccountOverview.tsx @@ -673,6 +673,7 @@ export const StrategyCard = ({ strategySubtext, handledTokenSymbol, apy, + apyHeader, } = strat const currentPositionFtm = new BigNumber( currentDeposits.toFixed(2) @@ -708,7 +709,7 @@ export const StrategyCard = ({
- {apy &&

Interest Rate

} + {apy &&

{apyHeader ?? 'Interest Rate'}

}

{apy}

{onClick ? : null} diff --git a/package.json b/package.json index 1edfe23c90..8c240021b5 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,7 @@ "node-fetch": "^2.6.7", "numbro": "^2.3.6", "papaparse": "^5.3.2", + "psyfi-euros-test": "^0.0.1-rc.33", "pyth-staking-api": "^1.2.17", "qr-code-styling": "^1.6.0-rc.1", "rc-slider": "^9.7.5", diff --git a/yarn.lock b/yarn.lock index e1ba2d76cd..3044edbcda 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16618,6 +16618,29 @@ psl@^1.1.28, psl@^1.1.33: resolved "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz" integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== +psyfi-euros-test@^0.0.1-rc.33: + version "0.0.1-rc.33" + resolved "https://registry.yarnpkg.com/psyfi-euros-test/-/psyfi-euros-test-0.0.1-rc.33.tgz#23646d3a4284db3f8d5b5ba42999b1c0e2824804" + integrity sha512-vRCz2XzXxdQ1V9QGqxY8L1JWL687Au/FNik9lthO6n+NpfT3un7bvdWkeyyhNljfmhUDDW1zarvcIp/cAFj4VA== + dependencies: + "@project-serum/anchor" "0.23.0" + "@project-serum/common" "^0.0.1-beta.3" + "@project-serum/serum" "^0.13.61" + "@solana/spl-token" "0.1.8" + "@solana/web3.js" "^1.35.1" + psystake-test "0.0.1-rc.8" + +psystake-test@0.0.1-rc.8: + version "0.0.1-rc.8" + resolved "https://registry.yarnpkg.com/psystake-test/-/psystake-test-0.0.1-rc.8.tgz#67ce3c7546c47ac44bd213997fadcee346efe440" + integrity sha512-wng85jJDM8SwVeH/6fUMKBxHiVPl6SwFm6Y1j3fNmhqEINwu1L+kShO3YCIhPN7oK2tox7Fb7tUrXdbk5HQ54g== + dependencies: + "@project-serum/anchor" "^0.23.0" + "@project-serum/common" "^0.0.1-beta.3" + "@project-serum/serum" "^0.13.61" + "@solana/spl-token" "^0.1.8" + "@solana/web3.js" "^1.35.1" + public-encrypt@^4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.3.tgz#4fcc9d77a07e48ba7527e7cbe0de33d0701331e0"