From e95676c77804f4c5af9d6977ad8d76ea2417486c Mon Sep 17 00:00:00 2001 From: 0xodia <89805726+0xodia@users.noreply.github.com> Date: Tue, 14 Jun 2022 12:54:14 -0400 Subject: [PATCH] Add Solend to treasury investments UI (#749) * add withdraw * complete solend investment integration * DRY investment mint * remove console.log * fix case where token has no strategy in main market * fix case where token has no strategy in main market for withdraw too --- Strategies/components/DepositModal.tsx | 16 +- Strategies/components/SolendModalContent.tsx | 57 ++ .../components/solend/SolendDeposit.tsx | 376 +++++++++++++ .../components/solend/SolendWithdraw.tsx | 381 +++++++++++++ Strategies/protocols/solend/index.ts | 511 ++++++++++++++++++ Strategies/store/useStrategiesStore.tsx | 6 +- Strategies/types/types.ts | 14 + components/AdditionalProposalOptions.tsx | 3 + .../TreasuryAccount/AccountOverview.tsx | 195 +++++-- .../programs/associatedTokenAccount.tsx | 2 +- 10 files changed, 1514 insertions(+), 47 deletions(-) create mode 100644 Strategies/components/SolendModalContent.tsx create mode 100644 Strategies/components/solend/SolendDeposit.tsx create mode 100644 Strategies/components/solend/SolendWithdraw.tsx create mode 100644 Strategies/protocols/solend/index.ts diff --git a/Strategies/components/DepositModal.tsx b/Strategies/components/DepositModal.tsx index 7d3943adfc..3ac535c1a1 100644 --- a/Strategies/components/DepositModal.tsx +++ b/Strategies/components/DepositModal.tsx @@ -1,11 +1,13 @@ import Modal from '@components/Modal' import ModalHeader from './ModalHeader' +import SolendModalContent from './SolendModalContent' import MangoDeposit from './MangoDepositComponent' import BigNumber from 'bignumber.js' +import { SolendStrategy } from 'Strategies/types/types' const DepositModal = ({ onClose, - isOpen, + proposedInvestment, handledMint, apy, protocolName, @@ -20,8 +22,9 @@ const DepositModal = ({ const currentPositionFtm = new BigNumber( currentPosition.toFixed(0) ).toFormat() + return ( - + - + {protocolName === 'Solend' ? ( + + ) : null} {protocolName === 'Mango' ? ( { + const [proposalType, setProposalType] = useState('Deposit') + + const tabs = [DEPOSIT, WITHDRAW] + + return ( +
+
+ setProposalType(v)} + values={tabs} + /> +
+ {proposalType === WITHDRAW && ( + + )} + {proposalType === DEPOSIT && ( + + )} +
+ ) +} + +export default SolendDepositComponent diff --git a/Strategies/components/solend/SolendDeposit.tsx b/Strategies/components/solend/SolendDeposit.tsx new file mode 100644 index 0000000000..62ef146836 --- /dev/null +++ b/Strategies/components/solend/SolendDeposit.tsx @@ -0,0 +1,376 @@ +import { PublicKey } from '@blockworks-foundation/mango-client' +import Button, { LinkButton } from '@components/Button' +import Input from '@components/inputs/Input' +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 { + getMintDecimalAmount, + getMintMinAmountAsDecimal, + getMintNaturalAmountFromDecimalAsBN, +} from '@tools/sdk/units' +import { precision } from '@utils/formatting' +import tokenService from '@utils/services/token' +import BigNumber from 'bignumber.js' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import useWalletStore from 'stores/useWalletStore' +import { SolendStrategy } from 'Strategies/types/types' +import AdditionalProposalOptions from '@components/AdditionalProposalOptions' +import { validateInstruction } from '@utils/instructionTools' +import * as yup from 'yup' +import { AssetAccount } from '@utils/uiTypes/assets' +import Select from '@components/inputs/Select' +import { + CreateSolendStrategyParams, + cTokenExchangeRate, + getReserveData, + SolendSubStrategy, +} from 'Strategies/protocols/solend' +import useVotePluginsClientStore from 'stores/useVotePluginsClientStore' + +const SOL_BUFFER = 0.02 + +const SolendDeposit = ({ + proposedInvestment, + handledMint, + createProposalFcn, + governedTokenAccount, +}: { + proposedInvestment: SolendStrategy + handledMint: string + createProposalFcn: CreateSolendStrategyParams + governedTokenAccount: AssetAccount +}) => { + const router = useRouter() + const { fmtUrlWithCluster } = useQueryContext() + const { + proposals, + realmInfo, + realm, + ownVoterWeight, + mint, + councilMint, + symbol, + } = useRealm() + const [isDepositing, setIsDepositing] = useState(false) + const [deposits, setDeposits] = useState<{ + [reserveAddress: string]: number + }>({}) + const [voteByCouncil, setVoteByCouncil] = useState(false) + const client = useVotePluginsClientStore( + (s) => s.state.currentRealmVotingClient + ) + const connection = useWalletStore((s) => s.connection) + const wallet = useWalletStore((s) => s.current) + const tokenInfo = tokenService.getTokenInfo(handledMint) + const { + governedTokenAccountsWithoutNfts, + auxiliaryTokenAccounts, + canUseTransferInstruction, + } = useGovernanceAssets() + + const treasuryAmount = new BN( + governedTokenAccount.isSol + ? governedTokenAccount.extensions.amount!.toNumber() + : governedTokenAccount.extensions.token!.account.amount + ) + const mintInfo = governedTokenAccount.extensions?.mint?.account + const tokenSymbol = tokenService.getTokenInfo( + governedTokenAccount.extensions.mint!.publicKey.toBase58() + )?.symbol + const [form, setForm] = useState<{ + title: string + description: string + amount?: number + reserve: SolendSubStrategy + }>({ + title: '', + description: '', + amount: undefined, + reserve: + proposedInvestment.reserves.find((reserve) => reserve.isPrimary) ?? + proposedInvestment.reserves[0]!, + }) + const [formErrors, setFormErrors] = useState({}) + const proposalTitle = `Deposit ${form.amount} ${ + tokenSymbol || 'tokens' + } to the Solend ${form.reserve.marketName} pool` + const handleSetForm = ({ propertyName, value }) => { + setFormErrors({}) + setForm({ ...form, [propertyName]: value }) + } + 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 = () => { + handleSetForm({ + propertyName: 'amount', + value: parseFloat( + Math.max( + Number(mintMinAmount), + Math.min(Number(Number.MAX_SAFE_INTEGER), Number(form.amount)) + ).toFixed(currentPrecision) + ), + }) + } + + // Solend + useEffect(() => { + const getSlndCTokens = async () => { + const accounts = [ + ...governedTokenAccountsWithoutNfts, + ...auxiliaryTokenAccounts, + ] + + const relevantAccs = accounts + .map((acc) => { + const reserve = (proposedInvestment as SolendStrategy)?.reserves.find( + (reserve) => + reserve.mintAddress === handledMint && + reserve.collateralMintAddress === + acc.extensions.mint?.publicKey.toBase58() + ) + if (!reserve || !proposedInvestment) return null + + return { + acc, + reserve, + } + }) + .filter(Boolean) + + const reserveStats = await getReserveData( + relevantAccs.map((data) => data!.reserve.reserveAddress) + ) + + const results = Object.fromEntries( + relevantAccs.map((data) => { + const reserve = data!.reserve + const account = data!.acc + + const stat = reserveStats.find( + (stat) => stat.reserve.lendingMarket === reserve.marketAddress + )! + + return [ + reserve.reserveAddress, + ((account.extensions.amount?.toNumber() ?? 0) * + cTokenExchangeRate(stat)) / + 10 ** reserve.decimals, + ] + }) + ) + setDeposits(results) + } + getSlndCTokens() + }, []) + + const handleDeposit = async () => { + const isValid = await validateInstruction({ schema, form, setFormErrors }) + if (!isValid) { + return + } + 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 + ) + const defaultProposalMint = voteByCouncil + ? realm?.account.config.councilMint + : !mint?.supply.isZero() || + realm?.account.config.useMaxCommunityVoterWeightAddin + ? realm!.account.communityMint + : !councilMint?.supply.isZero() + ? realm!.account.config.councilMint + : undefined + + const proposalAddress = await createProposalFcn( + rpcContext, + { + ...form, + amountFmt: form.amount!.toFixed(4), + bnAmount: getMintNaturalAmountFromDecimalAsBN( + form.amount as number, + governedTokenAccount.extensions.mint!.account.decimals + ), + proposalCount: Object.keys(proposals).length, + action: 'Deposit', + }, + realm!, + governedTokenAccount!, + ownTokenRecord, + defaultProposalMint!, + governedTokenAccount!.governance!.account!.proposalCount, + false, + connection, + client + ) + const url = fmtUrlWithCluster( + `/dao/${symbol}/proposal/${proposalAddress}` + ) + router.push(url) + } catch (e) { + console.log(e) + throw e + } + setIsDepositing(false) + } + const schema = yup.object().shape({ + amount: yup + .number() + .required('Amount is required') + .min(mintMinAmount) + .max(maxAmount.toNumber()), + reserve: yup.object().required('Lending market address is required'), + }) + + return ( +
+ +
+ 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} + /> +
+
+ Current Deposits + + {deposits[form.reserve.reserveAddress]?.toFixed(4) || 0}{' '} + {tokenInfo?.symbol} + +
+
+ Proposed Deposit + + {form.amount?.toLocaleString() || ( + Enter an amount + )}{' '} + + {form.amount && tokenInfo?.symbol} + + +
+
+ +
+ ) +} +SolendDeposit.whyDidYouRender = true + +export default SolendDeposit diff --git a/Strategies/components/solend/SolendWithdraw.tsx b/Strategies/components/solend/SolendWithdraw.tsx new file mode 100644 index 0000000000..786056c77b --- /dev/null +++ b/Strategies/components/solend/SolendWithdraw.tsx @@ -0,0 +1,381 @@ +import { PublicKey } from '@blockworks-foundation/mango-client' +import Button, { LinkButton } from '@components/Button' +import Input from '@components/inputs/Input' +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 { + getMintMinAmountAsDecimal, + getMintNaturalAmountFromDecimalAsBN, +} from '@tools/sdk/units' +import { precision } from '@utils/formatting' +import tokenService from '@utils/services/token' +import BigNumber from 'bignumber.js' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import useWalletStore from 'stores/useWalletStore' +import { SolendStrategy } from 'Strategies/types/types' +import useVotePluginsClientStore from 'stores/useVotePluginsClientStore' +import AdditionalProposalOptions from '@components/AdditionalProposalOptions' +import { validateInstruction } from '@utils/instructionTools' +import * as yup from 'yup' +import { AssetAccount } from '@utils/uiTypes/assets' +import Select from '@components/inputs/Select' +import { + CreateSolendStrategyParams, + cTokenExchangeRate, + getReserveData, + SolendSubStrategy, +} from 'Strategies/protocols/solend' + +const SolendWithdraw = ({ + proposedInvestment, + handledMint, + createProposalFcn, + governedTokenAccount, +}: { + proposedInvestment: SolendStrategy + handledMint: string + createProposalFcn: CreateSolendStrategyParams + governedTokenAccount: AssetAccount +}) => { + const { + governedTokenAccountsWithoutNfts, + auxiliaryTokenAccounts, + canUseTransferInstruction, + } = useGovernanceAssets() + const router = useRouter() + const { fmtUrlWithCluster } = useQueryContext() + const { + proposals, + realmInfo, + realm, + ownVoterWeight, + mint, + councilMint, + symbol, + } = useRealm() + const [isWithdrawing, setIsWithdrawing] = useState(false) + const [voteByCouncil, setVoteByCouncil] = useState(false) + const [deposits, setDeposits] = useState<{ + [reserveAddress: string]: { amount: number; amountExact: number } + }>({}) + const client = useVotePluginsClientStore( + (s) => s.state.currentRealmVotingClient + ) + const connection = useWalletStore((s) => s.connection) + const wallet = useWalletStore((s) => s.current) + const tokenInfo = tokenService.getTokenInfo(handledMint) + const mintInfo = governedTokenAccount.extensions?.mint?.account + const tokenSymbol = tokenService.getTokenInfo( + governedTokenAccount.extensions.mint!.publicKey.toBase58() + )?.symbol + const [form, setForm] = useState<{ + title: string + description: string + amount?: number + reserve: SolendSubStrategy + max: boolean + }>({ + title: '', + description: '', + amount: undefined, + reserve: + proposedInvestment.reserves.find((reserve) => reserve.isPrimary) ?? + proposedInvestment.reserves[0]!, + max: false, + }) + const [formErrors, setFormErrors] = useState({}) + const proposalTitle = `Withdraw ${form.amount} ${ + tokenSymbol || 'tokens' + } from the Solend ${form.reserve.marketName} pool` + const handleSetForm = ({ propertyName, value }) => { + setFormErrors({}) + setForm({ + ...form, + max: propertyName === 'amount' ? false : form.max, + [propertyName]: value, + }) + } + const mintMinAmount = mintInfo ? getMintMinAmountAsDecimal(mintInfo) : 1 + const maxAmount = new BigNumber( + deposits[form.reserve.reserveAddress]?.amount ?? 0 + ) + const maxAmountFtm = maxAmount.toFixed(4) + const currentPrecision = precision(mintMinAmount) + + // Solend + useEffect(() => { + const getSlndCTokens = async () => { + const accounts = [ + ...governedTokenAccountsWithoutNfts, + ...auxiliaryTokenAccounts, + ] + + const relevantAccs = accounts + .map((acc) => { + const reserve = (proposedInvestment as SolendStrategy)?.reserves.find( + (reserve) => + reserve.mintAddress === handledMint && + reserve.collateralMintAddress === + acc.extensions.mint?.publicKey.toBase58() + ) + if (!reserve || !proposedInvestment) return null + + return { + acc, + reserve, + } + }) + .filter(Boolean) + + const reserveStats = await getReserveData( + relevantAccs.map((data) => data!.reserve.reserveAddress) + ) + + setDeposits( + Object.fromEntries( + relevantAccs.map((data) => { + const reserve = data!.reserve + const account = data!.acc + + const stat = reserveStats.find( + (stat) => stat.reserve.lendingMarket === reserve.marketAddress + )! + + return [ + reserve.reserveAddress, + { + amount: + ((account.extensions.amount?.toNumber() ?? 0) * + cTokenExchangeRate(stat)) / + 10 ** reserve.decimals, + amountExact: account.extensions.amount?.toNumber() ?? 0, + }, + ] + }) + ) + ) + } + getSlndCTokens() + }, []) + + const validateAmountOnBlur = () => { + handleSetForm({ + propertyName: 'amount', + value: parseFloat( + Math.max( + Number(mintMinAmount), + Math.min(Number(Number.MAX_SAFE_INTEGER), Number(form.amount)) + ).toFixed(currentPrecision) + ), + }) + } + + const handleWithdraw = async () => { + const isValid = await validateInstruction({ schema, form, setFormErrors }) + if (!isValid) { + return + } + try { + setIsWithdrawing(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 + ) + const defaultProposalMint = voteByCouncil + ? realm?.account.config.councilMint + : !mint?.supply.isZero() || + realm?.account.config.useMaxCommunityVoterWeightAddin + ? realm!.account.communityMint + : !councilMint?.supply.isZero() + ? realm!.account.config.councilMint + : undefined + + const reserveStat = await getReserveData([form.reserve.reserveAddress]) + + const proposalAddress = await createProposalFcn( + rpcContext, + { + ...form, + bnAmount: form.max + ? new BN(deposits[form.reserve.reserveAddress].amountExact) + : getMintNaturalAmountFromDecimalAsBN( + (form.amount as number) / cTokenExchangeRate(reserveStat[0]), + governedTokenAccount.extensions.mint!.account.decimals + ), + amountFmt: ( + (form.amount as number) / cTokenExchangeRate(reserveStat[0]) + ).toFixed(4), + proposalCount: Object.keys(proposals).length, + action: 'Withdraw', + }, + realm!, + governedTokenAccount!, + ownTokenRecord, + defaultProposalMint!, + governedTokenAccount!.governance!.account!.proposalCount, + false, + connection, + client + ) + const url = fmtUrlWithCluster( + `/dao/${symbol}/proposal/${proposalAddress}` + ) + router.push(url) + } catch (e) { + console.log(e) + throw e + } + setIsWithdrawing(false) + } + const schema = yup.object().shape({ + amount: yup + .number() + .required('Amount is required') + .max(deposits[form.reserve.reserveAddress]?.amount), + reserve: yup.object().required('Lending market address is required'), + }) + + return ( +
+ +
+ Amount +
+ Bal: {maxAmountFtm} + { + setFormErrors({}) + setForm({ + ...form, + amount: maxAmount.toNumber(), + max: true, + }) + }} + 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} + /> +
+
+ Current Deposits + + {deposits[form.reserve.reserveAddress]?.amount.toFixed(4) || 0}{' '} + {tokenInfo?.symbol} + +
+
+ Proposed Withdraw + + {form.amount?.toLocaleString() || ( + Enter an amount + )}{' '} + + {form.amount && tokenInfo?.symbol} + + +
+
+ +
+ ) +} + +export default SolendWithdraw diff --git a/Strategies/protocols/solend/index.ts b/Strategies/protocols/solend/index.ts new file mode 100644 index 0000000000..3289dd0e29 --- /dev/null +++ b/Strategies/protocols/solend/index.ts @@ -0,0 +1,511 @@ +import { BN } from '@project-serum/anchor' +import { + ProgramAccount, + Realm, + getInstructionDataFromBase64, + RpcContext, + serializeInstructionToBase64, + TokenOwnerRecord, +} from '@solana/spl-governance' +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + NATIVE_MINT, + Token, + TOKEN_PROGRAM_ID, +} from '@solana/spl-token' +import { PublicKey, SystemProgram } from '@solana/web3.js' +import { + depositReserveLiquidityInstruction, + redeemReserveCollateralInstruction, + syncNative, +} from '@solendprotocol/solend-sdk' +import tokenService from '@utils/services/token' +import { + createProposal, + InstructionDataWithHoldUpTime, +} from 'actions/createProposal' +import axios from 'axios' +import { SolendStrategy } from 'Strategies/types/types' + +import { VotingClient } from '@utils/uiTypes/VotePlugin' +import { AssetAccount } from '@utils/uiTypes/assets' +import { ConnectionContext } from '@utils/connection' +import BigNumber from 'bignumber.js' + +const MAINNET_PROGRAM = 'So1endDq2YkqhipRh3WViPa8hdiSpxWy6z3Z6tMCpAo' +const DEVNET_PROGRAM = 'ALend7Ketfx5bxh6ghsCDXAoDrhvEmsXT3cynB6aPLgx' + +export const SOLEND = 'Solend' +const SOLEND_SYMBOL = 'SLND' +const SOLEND_PROTOCOL_LOGO_URI = + 'https://solend-image-assets.s3.us-east-2.amazonaws.com/1280-circle.png' + +const SOLEND_ENDPOINT = 'https://api.solend.fi' + +export type CreateSolendStrategyParams = ( + rpcContext: RpcContext, + form: { + title: string + description: string + action: 'Deposit' | 'Withdraw' + bnAmount: BN + amountFmt: string + proposalCount: number + reserve: SolendSubStrategy + }, + realm: ProgramAccount, + treasuaryAccount: AssetAccount, + tokenOwnerRecord: ProgramAccount, + governingTokenMint: PublicKey, + proposalIndex: number, + isDraft: boolean, + connection: ConnectionContext, + client?: VotingClient +) => Promise + +type Config = Array + +type MarketConfig = { + name: string + isPrimary: boolean + description: string + creator: string + address: string + authorityAddress: string + reserves: Array +} + +type ReserveConfig = { + liquidityToken: { + coingeckoID: string + decimals: number + logo: string + mint: string + name: string + symbol: string + volume24h: number + } + pythOracle: string + switchboardOracle: string + address: string + collateralMintAddress: string + collateralSupplyAddress: string + liquidityAddress: string + liquidityFeeReceiverAddress: string + userSupplyCap: number +} + +type ReserveStat = { + reserve: { + lendingMarket: string + liquidity: { + mintPubkey: string + mintDecimals: number + supplyPubkey: string + pythOracle: string + switchboardOracle: string + availableAmount: string + borrowedAmountWads: string + cumulativeBorrowRateWads: string + marketPrice: string + } + collateral: { + mintPubkey: string + mintTotalSupply: string + supplyPubkey: string + } + } + rates: { + supplyInterest: string + borrowInterest: string + } +} + +export type SolendSubStrategy = { + marketAddress: string + marketName: string + reserveAddress: string + mintAddress: string + logo: string + symbol: string + decimals: number + liquidity: number + supplyApy: number + isPrimary: boolean + liquidityAddress: string + collateralMintAddress: string + marketAuthorityAddress: string +} + +export async function getReserveData( + reserveIds: Array +): Promise> { + if (!reserveIds.length) return [] + const stats = ( + await ( + await axios.get( + `${SOLEND_ENDPOINT}/v1/reserves?ids=${reserveIds.join(',')}` + ) + ).data + ).results as Array + + return stats +} + +export function cTokenExchangeRate(reserve: ReserveStat) { + return new BigNumber(reserve.reserve.liquidity.availableAmount ?? '0') + .plus( + new BigNumber(reserve.reserve.liquidity.borrowedAmountWads).shiftedBy(-18) + ) + .dividedBy(new BigNumber(reserve.reserve.collateral.mintTotalSupply)) + .toNumber() +} + +export async function getReserve(): Promise { + return await ( + await axios.get(`${SOLEND_ENDPOINT}/v1/markets/configs?scope=solend`) + ).data +} + +export async function getConfig(): Promise { + return await ( + await axios.get(`${SOLEND_ENDPOINT}/v1/markets/configs?scope=solend`) + ).data +} + +export async function getReserves(): Promise { + const config = await getConfig() + const reserves = config.flatMap((market) => + market.reserves.map((reserve) => ({ + marketName: market.name, + marketDescription: market.description, + marketAddress: market.address, + marketPrimary: market.isPrimary, + marketAuthorityAddress: market.authorityAddress, + ...reserve, + })) + ) + + return reserves +} + +export async function getSolendStrategies() { + const strats: SolendStrategy[] = [] + + // method to fetch solend strategies + const config = await getConfig() + const reserves = config.flatMap((market) => + market.reserves.map((reserve) => ({ + marketName: market.name, + marketDescription: market.description, + marketAddress: market.address, + marketPrimary: market.isPrimary, + marketAuthorityAddress: market.authorityAddress, + ...reserve, + })) + ) + + const stats = await getReserveData(reserves.map((reserve) => reserve.address)) + + const mergedData = reserves.map((reserve, index) => ({ + marketName: + reserve.marketName.charAt(0).toUpperCase() + reserve.marketName.slice(1), + marketAddress: reserve.marketAddress, + reserveAddress: reserve.address, + mintAddress: reserve.liquidityToken.mint, + decimals: reserve.liquidityToken.decimals, + liquidityAddress: reserve.liquidityAddress, + collateralMintAddress: reserve.collateralMintAddress, + marketAuthorityAddress: reserve.marketAuthorityAddress, + isPrimary: reserve.marketPrimary, + logo: reserve.liquidityToken.logo, + symbol: reserve.liquidityToken.symbol, + liquidity: + (Number(stats[index].reserve.liquidity.availableAmount) / + 10 ** stats[index].reserve.liquidity.mintDecimals) * + (Number(stats[index].reserve.liquidity.marketPrice) / 10 ** 18), + supplyApy: Number(stats[index].rates.supplyInterest), + })) as Array + + const aggregatedData = mergedData.reduce( + (acc, reserve) => ({ + ...acc, + [reserve.symbol]: (acc[reserve.symbol] ?? []).concat(reserve), + }), + {} as { + [symbol: string]: typeof mergedData + } + ) + + for (const [symbol, reserves] of Object.entries(aggregatedData)) { + const tokenData = reserves[0] + const maxApy = Math.max(...reserves.map((reserve) => reserve.supplyApy)) + const totalLiquidity = reserves.reduce( + (acc, reserve) => acc + reserve.liquidity, + 0 + ) + + strats.push({ + liquidity: totalLiquidity, + handledTokenSymbol: symbol, + apy: + reserves.length > 1 + ? `Up to ${maxApy.toFixed(2)}%` + : `${maxApy.toFixed(2)}%`, + protocolName: SOLEND, + protocolSymbol: SOLEND_SYMBOL, + handledMint: tokenData.mintAddress, + handledTokenImgSrc: tokenData.logo, + protocolLogoSrc: SOLEND_PROTOCOL_LOGO_URI, + strategyName: 'Deposit', + strategyDescription: + 'Earn interest on your treasury assets by depositing into Solend.', + isGenericItem: false, + reserves: reserves, + createProposalFcn: handleSolendAction, + }) + } + + return strats +} + +async function handleSolendAction( + rpcContext: RpcContext, + form: { + action: 'Deposit' | 'Withdraw' + title: string + description: string + bnAmount: BN + reserve: SolendSubStrategy + amountFmt: string + }, + realm: ProgramAccount, + matchedTreasury: AssetAccount, + tokenOwnerRecord: ProgramAccount, + governingTokenMint: PublicKey, + proposalIndex: number, + isDraft: boolean, + connection: ConnectionContext, + client?: VotingClient +) { + const isSol = matchedTreasury.isSol + const insts: InstructionDataWithHoldUpTime[] = [] + const owner = isSol + ? matchedTreasury!.pubkey + : matchedTreasury!.extensions!.token!.account.owner + + const slndProgramAddress = + connection.cluster === 'mainnet' ? MAINNET_PROGRAM : DEVNET_PROGRAM + + const ctokenATA = await Token.getAssociatedTokenAddress( + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + new PublicKey(form.reserve.collateralMintAddress), + owner, + true + ) + + const liquidityATA = await Token.getAssociatedTokenAddress( + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + new PublicKey(form.reserve.mintAddress), + owner, + true + ) + + let createAtaInst + + if (form.action === 'Deposit') { + const depositAccountInfo = await connection.current.getAccountInfo( + ctokenATA + ) + if (!depositAccountInfo) { + // generate the instruction for creating the ATA + createAtaInst = Token.createAssociatedTokenAccountInstruction( + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + new PublicKey(form.reserve.collateralMintAddress), + ctokenATA, + owner, + owner + ) + } + } else { + const withdrawAccountInfo = await connection.current.getAccountInfo( + liquidityATA + ) + if (!withdrawAccountInfo) { + // generate the instruction for creating the ATA + createAtaInst = Token.createAssociatedTokenAccountInstruction( + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + matchedTreasury.extensions.token!.publicKey, + liquidityATA, + owner, + owner + ) + } + } + + if (createAtaInst) { + const createAtaInstObj = { + data: getInstructionDataFromBase64( + serializeInstructionToBase64(createAtaInst) + ), + holdUpTime: matchedTreasury.governance!.account!.config + .minInstructionHoldUpTime, + prerequisiteInstructions: [], + chunkSplitByDefault: true, + } + insts.push(createAtaInstObj) + } + + const setupInsts: InstructionDataWithHoldUpTime[] = [] + const cleanupInsts: InstructionDataWithHoldUpTime[] = [] + + if (isSol) { + const userWSOLAccountInfo = await connection.current.getAccountInfo( + liquidityATA + ) + + const rentExempt = await Token.getMinBalanceRentForExemptAccount( + connection.current + ) + + const sendAction = form.action === 'Deposit' + + const transferLamportsIx = SystemProgram.transfer({ + fromPubkey: owner, + toPubkey: liquidityATA, + lamports: + (userWSOLAccountInfo ? 0 : rentExempt) + + (sendAction ? form.bnAmount.toNumber() : 0), + }) + + const transferLamportInst = { + data: getInstructionDataFromBase64( + serializeInstructionToBase64(transferLamportsIx) + ), + holdUpTime: matchedTreasury.governance!.account!.config + .minInstructionHoldUpTime, + prerequisiteInstructions: [], + chunkSplitByDefault: true, + } + + setupInsts.push(transferLamportInst) + + const closeWSOLAccountIx = Token.createCloseAccountInstruction( + TOKEN_PROGRAM_ID, + liquidityATA, + owner, + owner, + [] + ) + + const closeWSOLInst = { + data: getInstructionDataFromBase64( + serializeInstructionToBase64(closeWSOLAccountIx) + ), + holdUpTime: matchedTreasury.governance!.account!.config + .minInstructionHoldUpTime, + prerequisiteInstructions: [], + chunkSplitByDefault: true, + } + + if (userWSOLAccountInfo) { + const syncIx = syncNative(liquidityATA) + const syncInst = { + data: getInstructionDataFromBase64( + serializeInstructionToBase64(syncIx) + ), + holdUpTime: matchedTreasury.governance!.account!.config + .minInstructionHoldUpTime, + prerequisiteInstructions: [], + chunkSplitByDefault: true, + } + if (sendAction) { + setupInsts.push(syncInst) + } else { + cleanupInsts.push(closeWSOLInst) + } + } else { + const createUserWSOLAccountIx = Token.createAssociatedTokenAccountInstruction( + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + NATIVE_MINT, + liquidityATA, + owner, + owner + ) + const createUserWSOLAccountInst = { + data: getInstructionDataFromBase64( + serializeInstructionToBase64(createUserWSOLAccountIx) + ), + holdUpTime: matchedTreasury.governance!.account!.config + .minInstructionHoldUpTime, + prerequisiteInstructions: [], + chunkSplitByDefault: true, + } + setupInsts.push(createUserWSOLAccountInst) + cleanupInsts.push(closeWSOLInst) + } + } + + const actionIx = + form.action === 'Deposit' + ? depositReserveLiquidityInstruction( + form.bnAmount, + liquidityATA, + ctokenATA, + new PublicKey(form.reserve.reserveAddress), + new PublicKey(form.reserve.liquidityAddress), + new PublicKey(form.reserve.collateralMintAddress), + new PublicKey(form.reserve.marketAddress), + new PublicKey(form.reserve.marketAuthorityAddress), + owner, + new PublicKey(slndProgramAddress) + ) + : redeemReserveCollateralInstruction( + form.bnAmount, + ctokenATA, + liquidityATA, + new PublicKey(form.reserve.reserveAddress), + new PublicKey(form.reserve.collateralMintAddress), + new PublicKey(form.reserve.liquidityAddress), + new PublicKey(form.reserve.marketAddress), + new PublicKey(form.reserve.marketAuthorityAddress), + owner, + new PublicKey(slndProgramAddress) + ) + + const depositSolendInsObj = { + data: getInstructionDataFromBase64(serializeInstructionToBase64(actionIx)), + holdUpTime: matchedTreasury.governance!.account!.config + .minInstructionHoldUpTime, + prerequisiteInstructions: [], + chunkSplitByDefault: true, + } + insts.push(depositSolendInsObj) + + const proposalAddress = await createProposal( + rpcContext, + realm, + matchedTreasury.governance!.pubkey, + tokenOwnerRecord, + form.title || + `${form.action} ${form.amountFmt} ${ + tokenService.getTokenInfo( + matchedTreasury.extensions.mint!.publicKey.toBase58() + )?.symbol || 'tokens' + } ${form.action === 'Deposit' ? 'into' : 'from'} the Solend ${ + form.reserve.marketName + } pool`, + form.description, + governingTokenMint, + proposalIndex, + [...setupInsts, ...insts, ...cleanupInsts], + isDraft, + client + ) + return proposalAddress +} diff --git a/Strategies/store/useStrategiesStore.tsx b/Strategies/store/useStrategiesStore.tsx index e3499d6803..cb466bf3df 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 { getSolendStrategies } from 'Strategies/protocols/solend' import { TreasuryStrategy } from 'Strategies/types/types' import create, { State } from 'zustand' @@ -19,8 +20,11 @@ const useStrategiesStore = create((set, _get) => ({ }) try { const mango = await tvl(Date.now() / 1000, connection) + const solend = await getSolendStrategies() + //add fetch functions for your protocol in promise.all - const strategies: TreasuryStrategy[] = [...mango] + const strategies: TreasuryStrategy[] = [...solend, ...mango] + set((s) => { s.strategies = strategies }) diff --git a/Strategies/types/types.ts b/Strategies/types/types.ts index 177036ae7a..fcec9d199c 100644 --- a/Strategies/types/types.ts +++ b/Strategies/types/types.ts @@ -9,6 +9,10 @@ import { PublicKey, TransactionInstruction } from '@solana/web3.js' import { VotingClient } from '@utils/uiTypes/VotePlugin' import { AssetAccount } from '@utils/uiTypes/assets' import { MarketStore } from 'Strategies/store/marketStore' +import { + CreateSolendStrategyParams, + SolendSubStrategy, +} from 'Strategies/protocols/solend' export interface TreasuryStrategy { //liquidity in $ @@ -16,6 +20,7 @@ export interface TreasuryStrategy { protocolSymbol: string apy: string protocolName: string + strategySubtext?: string handledMint: string handledTokenSymbol: string handledTokenImgSrc: string @@ -25,11 +30,20 @@ export interface TreasuryStrategy { //if you want to use custom component set this to false and add your custom //item and modal to strategywrapper component based on generic components isGenericItem?: boolean + createProposalFcn: any +} + +export type MangoStrategy = TreasuryStrategy & { //async function that pass all props needed to create proposal // if promise is successfully resolved it will automatically redirect to created proposal createProposalFcn: HandleCreateProposalWithStrategy } +export type SolendStrategy = TreasuryStrategy & { + reserves: Array + createProposalFcn: CreateSolendStrategyParams +} + export type HandleCreateProposalWithStrategy = ( { connection, wallet, programId, programVersion, walletPubkey }: RpcContext, handledMint: string, diff --git a/components/AdditionalProposalOptions.tsx b/components/AdditionalProposalOptions.tsx index 00c02ccef6..d57d9298b0 100644 --- a/components/AdditionalProposalOptions.tsx +++ b/components/AdditionalProposalOptions.tsx @@ -12,6 +12,7 @@ const AdditionalProposalOptions = ({ setTitle, setDescription, defaultTitle, + defaultDescription, voteByCouncil, setVoteByCouncil, }: { @@ -20,6 +21,7 @@ const AdditionalProposalOptions = ({ setTitle: (evt) => void setDescription: (evt) => void defaultTitle: string + defaultDescription?: string voteByCouncil: boolean setVoteByCouncil: (val) => void }) => { @@ -52,6 +54,7 @@ const AdditionalProposalOptions = ({ noMaxWidth={true} label="Proposal Description" placeholder={ + defaultDescription ?? 'Description of your proposal or use a github gist link (optional)' } wrapperClassName="mb-5" diff --git a/components/TreasuryAccount/AccountOverview.tsx b/components/TreasuryAccount/AccountOverview.tsx index c4f4d3bfd0..c5ae2a6a27 100644 --- a/components/TreasuryAccount/AccountOverview.tsx +++ b/components/TreasuryAccount/AccountOverview.tsx @@ -1,6 +1,6 @@ import Button, { LinkButton } from '@components/Button' import { getExplorerUrl } from '@components/explorer/tools' -import { getAccountName } from '@components/instructions/tools' +import { getAccountName, WSOL_MINT } from '@components/instructions/tools' import Modal from '@components/Modal' import useGovernanceAssets from '@hooks/useGovernanceAssets' import useQueryContext from '@hooks/useQueryContext' @@ -24,7 +24,7 @@ import Tooltip from '@components/Tooltip' import ConvertToMsol from './ConvertToMsol' import useStrategiesStore from 'Strategies/store/useStrategiesStore' import DepositModal from 'Strategies/components/DepositModal' -import { TreasuryStrategy } from 'Strategies/types/types' +import { SolendStrategy, TreasuryStrategy } from 'Strategies/types/types' import BigNumber from 'bignumber.js' import { MangoAccount } from '@blockworks-foundation/mango-client' import { @@ -37,10 +37,23 @@ import LoadingRows from './LoadingRows' import TradeOnSerum, { TradeOnSerumProps } from './TradeOnSerum' import { AccountType } from '@utils/uiTypes/assets' import CreateAta from './CreateAta' +import { + cTokenExchangeRate, + getReserveData, + SOLEND, +} from 'Strategies/protocols/solend' + +type InvestmentType = TreasuryStrategy & { + investedAmount: number +} const AccountOverview = () => { const router = useRouter() const { ownTokenRecord, ownCouncilTokenRecord } = useRealm() + const { + governedTokenAccounts, + auxiliaryTokenAccounts, + } = useGovernanceAssets() const currentAccount = useTreasuryAccountStore((s) => s.currentAccount) const nftsPerPubkey = useTreasuryAccountStore((s) => s.governanceNfts) const nftsCount = @@ -51,17 +64,16 @@ const AccountOverview = () => { const { fmtUrlWithCluster } = useQueryContext() const isNFT = currentAccount?.isNft const isSol = currentAccount?.isSol + const [loading, setLoading] = useState(true) const isSplToken = currentAccount?.type === AccountType.TOKEN const isAuxiliaryAccount = currentAccount?.type === AccountType.AuxiliaryToken - const connected = useWalletStore((s) => s.connected) const { canUseTransferInstruction } = useGovernanceAssets() - const connection = useWalletStore((s) => s.connection) + const { connection, connected } = useWalletStore((s) => s) const recentActivity = useTreasuryAccountStore((s) => s.recentActivity) const isLoadingRecentActivity = useTreasuryAccountStore( (s) => s.isLoadingRecentActivity ) const market = useMarketStore((s) => s) - const [currentMangoDeposits, setCurrentMangoDeposits] = useState(0) const [mngoAccounts, setMngoAccounts] = useState([]) const [openNftDepositModal, setOpenNftDepositModal] = useState(false) const [openCommonSendModal, setOpenCommonSendModal] = useState(false) @@ -70,33 +82,82 @@ const AccountOverview = () => { const accountPublicKey = currentAccount?.extensions.transferAddress const strategies = useStrategiesStore((s) => s.strategies) const [accountInvestments, setAccountInvestments] = useState< - TreasuryStrategy[] - >([]) - const [eligibleInvestments, setEligibleInvestments] = useState< - TreasuryStrategy[] + InvestmentType[] >([]) const [showStrategies, setShowStrategies] = useState(false) const [ proposedInvestment, setProposedInvestment, - ] = useState(null) + ] = useState(null) const [isCopied, setIsCopied] = useState(false) const [ tradeSerumInfo, setTradeSerumInfo, ] = useState(null) + const strategyMint = currentAccount?.isSol + ? WSOL_MINT + : currentAccount?.extensions.token?.account.mint.toString() + const visibleInvestments = strategies.filter( + (strat) => strat.handledMint === strategyMint + ) + const visibleAccounts = accountInvestments.filter( + (strat) => strat.handledMint === strategyMint + ) useEffect(() => { - if (strategies.length > 0) { - const eligibleInvestments = strategies.filter( - (strat) => - strat.handledMint === - currentAccount?.extensions.token?.account.mint.toString() + const getSlndCTokens = async () => { + const accounts = [ + ...governedTokenAccounts, + ...auxiliaryTokenAccounts, + ...(currentAccount?.isSol ? [currentAccount] : []), + ] + + const solendStrategy = visibleInvestments.filter( + (strat) => strat.protocolName === SOLEND + )[0] + + const relevantAccs = accounts + .map((acc) => { + const reserve = (solendStrategy as SolendStrategy)?.reserves.find( + (reserve) => + reserve.mintAddress === strategyMint && + reserve.collateralMintAddress === + acc.extensions.mint?.publicKey.toBase58() + ) + if (!reserve || !solendStrategy) return null + + return { + acc, + reserve, + } + }) + .filter(Boolean) + + const reserveStats = await getReserveData( + relevantAccs.map((data) => data!.reserve.reserveAddress) ) - setEligibleInvestments(eligibleInvestments) + + return relevantAccs.map((data) => { + const reserve = data!.reserve + const account = data!.acc + + const stat = reserveStats.find( + (stat) => stat.reserve.lendingMarket === reserve.marketAddress + )! + + return { + ...solendStrategy, + apy: `${reserve.supplyApy.toFixed(2)}%`, + protocolName: solendStrategy.protocolName, + strategySubtext: `${reserve.marketName} Pool`, + investedAmount: + ((account.extensions.amount?.toNumber() ?? 0) * + cTokenExchangeRate(stat)) / + 10 ** reserve.decimals, + } + }) as Array } - }, [currentAccount, strategies]) - useEffect(() => { + const handleGetMangoAccounts = async () => { const currentAccountMint = currentAccount?.extensions.token?.account.mint const currentPositions = calculateAllDepositsInMangoAccountsForMint( @@ -104,20 +165,37 @@ const AccountOverview = () => { currentAccountMint!, market ) - setCurrentMangoDeposits(currentPositions) if (currentPositions > 0) { - setAccountInvestments( - eligibleInvestments.filter((x) => x.protocolName === MANGO) - ) - } else { - setAccountInvestments([]) + return strategies + .map((invest) => ({ + ...invest, + investedAmount: currentPositions, + })) + .filter((x) => x.protocolName === MANGO) } + + return [] } - if (eligibleInvestments.filter((x) => x.protocolName === MANGO).length) { - handleGetMangoAccounts() + + const loadData = async () => { + const requests = [] as Array>> + if (visibleInvestments.filter((x) => x.protocolName === MANGO).length) { + requests.push(handleGetMangoAccounts()) + } + if (visibleInvestments.filter((x) => x.protocolName === SOLEND).length) { + requests.push(getSlndCTokens()) + } + + const results = await Promise.all(requests) + setLoading(false) + + setAccountInvestments(results.flatMap((x) => x)) } - }, [eligibleInvestments, currentAccount, mngoAccounts]) + + loadData() + }, [currentAccount, mngoAccounts]) + useEffect(() => { const getMangoAcccounts = async () => { const accounts = await tryGetMangoAccountsForOwner( @@ -129,7 +207,7 @@ const AccountOverview = () => { if (currentAccount) { getMangoAcccounts() } - }, [currentAccount, eligibleInvestments, market]) + }, [currentAccount, market]) useEffect(() => { if (isCopied) { @@ -140,6 +218,14 @@ const AccountOverview = () => { } }, [isCopied]) + useEffect(() => { + if (visibleInvestments.length && visibleAccounts.length === 0) { + setShowStrategies(true) + } else { + setShowStrategies(false) + } + }, [currentAccount, visibleAccounts.length, visibleInvestments.length]) + const handleCopyAddress = (address: string) => { navigator.clipboard.writeText(address) setIsCopied(true) @@ -149,6 +235,14 @@ const AccountOverview = () => { return null } + const deposits = visibleAccounts.reduce( + (acc, account) => ({ + ...acc, + [account.protocolName]: + (acc[account.protocolName] ?? 0) + account.investedAmount, + }), + {} + ) return ( <>
@@ -300,12 +394,17 @@ const AccountOverview = () => {
{showStrategies ? ( - eligibleInvestments.length > 0 ? ( - eligibleInvestments.map((strat, i) => ( + visibleInvestments.length > 0 ? ( + visibleInvestments.map((strat, i) => ( setProposedInvestment(strat)} + currentDeposits={deposits[strat.protocolName] ?? 0} + onClick={() => { + setProposedInvestment({ + ...strat, + investedAmount: deposits[strat.protocolName] ?? 0, + }) + }} strat={strat} /> )) @@ -316,18 +415,18 @@ const AccountOverview = () => {

) - ) : accountInvestments.length > 0 ? ( - accountInvestments.map((strat, i) => ( + ) : visibleAccounts.length > 0 ? ( + visibleAccounts.map((strat, i) => ( )) ) : (

- No investments for this account + {loading ? 'Loading...' : 'No investments for this account'}

)} @@ -371,13 +470,13 @@ const AccountOverview = () => { { setProposedInvestment(null) }} - isOpen={proposedInvestment} + proposedInvestment={proposedInvestment} protocolName={proposedInvestment.protocolName} protocolLogoSrc={proposedInvestment.protocolLogoSrc} handledTokenName={proposedInvestment.handledTokenSymbol} @@ -455,27 +554,37 @@ const AccountOverview = () => { interface StrategyCardProps { onClick?: () => void strat: TreasuryStrategy - currentMangoDeposits: number + currentDeposits: number } const StrategyCard = ({ onClick, strat, - currentMangoDeposits, + currentDeposits, }: StrategyCardProps) => { const { handledTokenImgSrc, strategyName, protocolName, + strategySubtext, handledTokenSymbol, apy, } = strat const currentPositionFtm = new BigNumber( - currentMangoDeposits.toFixed(0) + currentDeposits.toFixed(2) ).toFormat() return (
+ {strat.protocolLogoSrc ? ( + + ) : null} {handledTokenImgSrc ? ( ) : null}
-

{`${strategyName} ${handledTokenSymbol} on ${protocolName}`}

+

{`${strategyName} ${handledTokenSymbol} on ${protocolName}${ + strategySubtext ? ` - ${strategySubtext}` : '' + }`}

{`${currentPositionFtm} ${handledTokenSymbol}`}

diff --git a/components/instructions/programs/associatedTokenAccount.tsx b/components/instructions/programs/associatedTokenAccount.tsx index 7f9ec71ba7..d94c01bbb0 100644 --- a/components/instructions/programs/associatedTokenAccount.tsx +++ b/components/instructions/programs/associatedTokenAccount.tsx @@ -24,7 +24,7 @@ export const ATA_PROGRAM_INSTRUCTIONS = { Object.keys(SPL_TOKENS).find( (name) => SPL_TOKENS[name].mint?.toString() === tokenMint )! - ].name ?? 'unknown' + ]?.name ?? 'unknown' return (