diff --git a/README.md b/README.md index 5ca1eff..692d519 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ Any content produced by Solana, or developer resources that Solana provides, are for educational and inspiration purposes only. Solana does not encourage, induce or sanction the deployment of any such applications in violation of applicable laws or regulations. - ## TODO - [] Calculate deposit APY and borrow APY for home page @@ -14,4 +13,4 @@ Any content produced by Solana, or developer resources that Solana provides, are - [] Add liquidate view - [] Borrow view calculate available to you and borrow APY - [] Facuet add USDC that is using sol airdrop and USDC reserve to give user USDC -- [] Borrow - Convert target ccy to collateral on oposite side \ No newline at end of file +- [] Borrow - Convert target ccy to collateral on oposite side diff --git a/src/actions/account.ts b/src/actions/account.ts index 7d80fd4..1178dc3 100644 --- a/src/actions/account.ts +++ b/src/actions/account.ts @@ -5,7 +5,11 @@ import { SystemProgram, TransactionInstruction, } from "@solana/web3.js"; -import { LENDING_PROGRAM_ID, TOKEN_PROGRAM_ID, WRAPPED_SOL_MINT } from "../constants/ids"; +import { + LENDING_PROGRAM_ID, + TOKEN_PROGRAM_ID, + WRAPPED_SOL_MINT, +} from "../constants/ids"; import { LendingObligationLayout, TokenAccount } from "../models"; import { cache, TokenAccountParser } from "./../contexts/accounts"; diff --git a/src/actions/borrow.tsx b/src/actions/borrow.tsx index a03d2e5..959db55 100644 --- a/src/actions/borrow.tsx +++ b/src/actions/borrow.tsx @@ -108,7 +108,7 @@ export const borrow = async ( cleanupInstructions = []; const [authority] = await PublicKey.findProgramAddress( - [depositReserve.lendingMarket.toBuffer()], // which account should be authority + [depositReserve.lendingMarket.toBuffer()], LENDING_PROGRAM_ID ); diff --git a/src/actions/index.ts b/src/actions/index.ts index 44c0bd0..08e7609 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -1,4 +1,5 @@ export { borrow } from "./borrow"; export { deposit } from "./deposit"; +export { repay } from "./repay"; export { withdraw } from "./withdraw"; export * from "./account"; diff --git a/src/actions/repay.tsx b/src/actions/repay.tsx new file mode 100644 index 0000000..129a590 --- /dev/null +++ b/src/actions/repay.tsx @@ -0,0 +1,112 @@ +import { + Account, + Connection, + PublicKey, + TransactionInstruction, +} from "@solana/web3.js"; +import { sendTransaction } from "../contexts/connection"; +import { notify } from "../utils/notifications"; +import { + LendingReserve, +} from "./../models/lending/reserve"; +import { + repayInstruction, +} from "./../models/lending/repay"; +import { AccountLayout, Token } from "@solana/spl-token"; +import { LENDING_PROGRAM_ID, TOKEN_PROGRAM_ID } from "../constants/ids"; +import { findOrCreateAccountByMint } from "./account"; +import { LendingObligation, TokenAccount } from "../models"; + +export const repay = async ( + from: TokenAccount, // CollateralAccount + amountLamports: number, // in collateral token (lamports) + + // which loan to repay + obligation: LendingObligation, + + repayReserve: LendingReserve, + repayReserveAddress: PublicKey, + + withdrawReserve: LendingReserve, + withdrawReserveAddress: PublicKey, + + connection: Connection, + wallet: any +) => { + notify({ + message: "Repaing funds...", + description: "Please review transactions to approve.", + type: "warn", + }); + + // user from account + const signers: Account[] = []; + const instructions: TransactionInstruction[] = []; + const cleanupInstructions: TransactionInstruction[] = []; + + const accountRentExempt = await connection.getMinimumBalanceForRentExemption( + AccountLayout.span + ); + + const [authority] = await PublicKey.findProgramAddress( + [repayReserve.lendingMarket.toBuffer()], + LENDING_PROGRAM_ID + ); + + const fromAccount = from.pubkey; + + // create approval for transfer transactions + instructions.push( + Token.createApproveInstruction( + TOKEN_PROGRAM_ID, + fromAccount, + authority, + wallet.publicKey, + [], + amountLamports + ) + ); + + // get destination account + const toAccount = await findOrCreateAccountByMint( + wallet.publicKey, + wallet.publicKey, + instructions, + cleanupInstructions, + accountRentExempt, + withdrawReserve.liquidityMint, + signers + ); + + // TODO: add obligation + + // instructions.push( + // repayInstruction( + // amountLamports, + // fromAccount, + // toAccount, + // reserveAddress, + // reserve.collateralMint, + // reserve.liquiditySupply, + // authority + // ) + // ); + + try { + let tx = await sendTransaction( + connection, + wallet, + instructions.concat(cleanupInstructions), + signers, + true + ); + + notify({ + message: "Funds repaid.", + type: "success", + description: `Transaction - ${tx}`, + }); + } catch { + // TODO: + } +}; diff --git a/src/actions/withdraw.tsx b/src/actions/withdraw.tsx index f3dc88a..22c03b8 100644 --- a/src/actions/withdraw.tsx +++ b/src/actions/withdraw.tsx @@ -39,7 +39,7 @@ export const withdraw = async ( ); const [authority] = await PublicKey.findProgramAddress( - [reserve.lendingMarket.toBuffer()], // which account should be authority + [reserve.lendingMarket.toBuffer()], LENDING_PROGRAM_ID ); diff --git a/src/components/BorrowInput/index.tsx b/src/components/BorrowInput/index.tsx index 2bdf147..3cb23f8 100644 --- a/src/components/BorrowInput/index.tsx +++ b/src/components/BorrowInput/index.tsx @@ -1,65 +1,17 @@ import React, { useCallback, useMemo, useState } from "react"; -import { useLendingReserves, useTokenName, useUserBalance } from "../../hooks"; +import { useTokenName, useUserBalance } from "../../hooks"; import { LendingReserve, LendingReserveParser } from "../../models"; import { TokenIcon } from "../TokenIcon"; -import { getTokenName } from "../../utils/utils"; -import { Button, Card, Select } from "antd"; +import { Button, Card } from "antd"; import { cache, ParsedAccount } from "../../contexts/accounts"; import { NumericInput } from "../Input/numeric"; -import { useConnection, useConnectionConfig } from "../../contexts/connection"; +import { useConnection } from "../../contexts/connection"; import { useWallet } from "../../contexts/wallet"; import { borrow } from "../../actions"; import { PublicKey } from "@solana/web3.js"; +import { CollateralSelector } from "./../CollateralSelector"; import "./style.less"; -const { Option } = Select; - -const CollateralSelector = (props: { - reserve: LendingReserve; - mint?: string; - onMintChange: (id: string) => void; -}) => { - const { reserveAccounts } = useLendingReserves(); - const { tokenMap } = useConnectionConfig(); - - return ( - - ); -}; - export const BorrowInput = (props: { className?: string; reserve: LendingReserve; diff --git a/src/components/CollateralSelector/index.tsx b/src/components/CollateralSelector/index.tsx new file mode 100644 index 0000000..78d2bbf --- /dev/null +++ b/src/components/CollateralSelector/index.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { useLendingReserves } from "../../hooks"; +import { LendingReserve } from "../../models"; +import { TokenIcon } from "../TokenIcon"; +import { getTokenName } from "../../utils/utils"; +import { Select } from "antd"; +import { useConnectionConfig } from "../../contexts/connection"; + +const { Option } = Select; + +export const CollateralSelector = (props: { + reserve: LendingReserve; + mint?: string; + onMintChange: (id: string) => void; +}) => { + const { reserveAccounts } = useLendingReserves(); + const { tokenMap } = useConnectionConfig(); + + return ( + + ); +}; diff --git a/src/components/RepayInput/index.tsx b/src/components/RepayInput/index.tsx new file mode 100644 index 0000000..d6d9c91 --- /dev/null +++ b/src/components/RepayInput/index.tsx @@ -0,0 +1,128 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { useTokenName, useUserBalance } from "../../hooks"; +import { LendingObligation, LendingReserve, LendingReserveParser } from "../../models"; +import { TokenIcon } from "../TokenIcon"; +import { Button, Card } from "antd"; +import { cache, ParsedAccount } from "../../contexts/accounts"; +import { NumericInput } from "../Input/numeric"; +import { useConnection } from "../../contexts/connection"; +import { useWallet } from "../../contexts/wallet"; +import { repay } from "../../actions"; +import { PublicKey } from "@solana/web3.js"; +import { CollateralSelector } from "./../CollateralSelector"; +import "./style.less"; + +export const RepayInput = (props: { + className?: string; + reserve: LendingReserve; + obligation: LendingObligation; + address: PublicKey; +}) => { + const connection = useConnection(); + const { wallet } = useWallet(); + const [value, setValue] = useState(""); + + const repayReserve = props.reserve; + const repayReserveAddress = props.address; + const obligation = props.obligation; + + const [collateralReserveMint, setCollateralReserveMint] = useState(); + + const collateralReserve = useMemo(() => { + const id: string = + cache + .byParser(LendingReserveParser) + .find((acc) => acc === collateralReserveMint) || ""; + + return cache.get(id) as ParsedAccount; + }, [collateralReserveMint]); + + const name = useTokenName(repayReserve?.liquidityMint); + const { accounts: fromAccounts } = useUserBalance( + collateralReserve?.info.collateralMint + ); + // const collateralBalance = useUserBalance(reserve?.collateralMint); + + const onReoay = useCallback(() => { + if (!collateralReserve) { + return; + } + + repay( + fromAccounts[0], + parseFloat(value), + obligation, + repayReserve, + repayReserveAddress, + collateralReserve.info, + collateralReserve.pubkey, + connection, + wallet + ); + }, [ + connection, + wallet, + value, + obligation, + collateralReserve, + repayReserve, + fromAccounts, + repayReserveAddress, + ]); + + const bodyStyle: React.CSSProperties = { + display: "flex", + flex: 1, + justifyContent: "center", + alignItems: "center", + height: "100%", + }; + + return ( + +
+
+ How much would you like to repay? +
+
+ + { + setValue(val); + }} + autoFocus={true} + style={{ + fontSize: 20, + boxShadow: "none", + borderColor: "transparent", + outline: "transpaernt", + }} + placeholder="0.00" + /> +
{name}
+
+
Select collateral account?
+ + + +
+
+ ); +}; diff --git a/src/components/RepayInput/style.less b/src/components/RepayInput/style.less new file mode 100644 index 0000000..c4d8776 --- /dev/null +++ b/src/components/RepayInput/style.less @@ -0,0 +1,3 @@ +.borrow-input-title { + font-size: 1.05rem; +} \ No newline at end of file diff --git a/src/components/UserLendingCard/index.tsx b/src/components/UserLendingCard/index.tsx index 0803664..0c41844 100644 --- a/src/components/UserLendingCard/index.tsx +++ b/src/components/UserLendingCard/index.tsx @@ -115,7 +115,7 @@ export const UserLendingCard = (props: { - + diff --git a/src/contexts/lending.tsx b/src/contexts/lending.tsx index 28bf96c..185ebd6 100644 --- a/src/contexts/lending.tsx +++ b/src/contexts/lending.tsx @@ -7,6 +7,8 @@ import { isLendingMarket, LendingReserveParser, LendingReserve, + isLendingObligation, + LendingObligationParser, } from "./../models/lending"; import { cache, @@ -51,6 +53,12 @@ export const useLending = () => { item.account, LendingMarketParser ); + }else if (isLendingObligation(item.account)) { + return cache.add( + item.pubkey.toBase58(), + item.account, + LendingObligationParser, + ); } }, []); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index df1aa6f..7ca7e0a 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,3 +4,5 @@ export * from "./useLendingReserves"; export * from "./useTokenName"; export * from "./useUserBalance"; export * from "./useCollateralBalance"; +export * from "./useLendingObligations"; +export * from "./useUserObligations"; diff --git a/src/hooks/useLendingObligations.ts b/src/hooks/useLendingObligations.ts new file mode 100644 index 0000000..da87bdd --- /dev/null +++ b/src/hooks/useLendingObligations.ts @@ -0,0 +1,58 @@ +import { PublicKey } from "@solana/web3.js"; +import { useEffect, useState } from "react"; +import { LendingObligation, LendingObligationParser } from "../models/lending"; +import { cache, ParsedAccount } from "./../contexts/accounts"; + +const getLendingObligations = () => { + return cache + .byParser(LendingObligationParser) + .map((id) => cache.get(id)) + .filter((acc) => acc !== undefined) as any[]; +}; + +export function useLendingObligations() { + const [obligations, setObligations] = useState< + ParsedAccount[] + >([]); + + useEffect(() => { + setObligations(getLendingObligations()); + + const dispose = cache.emitter.onCache((args) => { + if (args.parser === LendingObligationParser) { + setObligations(getLendingObligations()); + } + }); + + return () => { + dispose(); + }; + }, [setObligations]); + + return { + obligations, + }; +} + +export function useLendingObligation(address: string | PublicKey) { + const id = typeof address === "string" ? address : address?.toBase58(); + const [obligationAccount, setObligationAccount] = useState< + ParsedAccount + >(); + + useEffect(() => { + setObligationAccount(cache.get(id)); + + const dispose = cache.emitter.onCache((args) => { + if (args.id === id) { + setObligationAccount(cache.get(id)); + } + }); + + return () => { + dispose(); + }; + }, [id, setObligationAccount]); + + return obligationAccount; +} diff --git a/src/hooks/useUserObligations.ts b/src/hooks/useUserObligations.ts new file mode 100644 index 0000000..b202f9f --- /dev/null +++ b/src/hooks/useUserObligations.ts @@ -0,0 +1,40 @@ +import { useMemo } from "react"; +import { useUserAccounts } from "./useUserAccounts"; +import { useLendingObligations } from "./useLendingObligations"; +import { TokenAccount } from "../models"; + +export function useUserObligations() { + const { userAccounts } = useUserAccounts(); + const { obligations } = useLendingObligations(); + + const accountsByMint = useMemo(() => { + return userAccounts.reduce((res, acc) => { + const id = acc.info.mint.toBase58(); + res.set(id, [...(res.get(id) || []), acc]); + return res; + }, new Map()) + }, + [userAccounts]); + + const userObligations = useMemo(() => { + if(accountsByMint.size === 0) { + return []; + } + + return obligations + .filter((acc) => accountsByMint.get(acc.info.tokenMint.toBase58()) !== undefined) + .map(ob => { + return { + oblication: ob, + userAccounts: [...accountsByMint.get(ob.info.tokenMint.toBase58())], + + // TODO: add total borrowed amount? + } + }); + }, [accountsByMint, obligations]); + + + return { + userObligations + }; +} diff --git a/src/models/lending/liquidate.ts b/src/models/lending/liquidate.ts index 9c9085c..206f84a 100644 --- a/src/models/lending/liquidate.ts +++ b/src/models/lending/liquidate.ts @@ -1,4 +1,8 @@ -import { PublicKey, SYSVAR_CLOCK_PUBKEY, TransactionInstruction } from "@solana/web3.js"; +import { + PublicKey, + SYSVAR_CLOCK_PUBKEY, + TransactionInstruction, +} from "@solana/web3.js"; import BN from "bn.js"; import { LENDING_PROGRAM_ID, TOKEN_PROGRAM_ID } from "../../constants/ids"; import { LendingInstruction } from "./lending"; @@ -32,7 +36,7 @@ export const liquidateInstruction = ( authority: PublicKey, dexMarket: PublicKey, dexOrderBookSide: PublicKey, - memory: PublicKey, + memory: PublicKey ): TransactionInstruction => { const dataLayout = BufferLayout.struct([ BufferLayout.u8("instruction"), @@ -56,7 +60,11 @@ export const liquidateInstruction = ( { pubkey: repayReserveLiquiditySupply, isSigner: false, isWritable: true }, { pubkey: withdrawReserve, isSigner: false, isWritable: true }, - { pubkey: withdrawReserveCollateralSupply, isSigner: false, isWritable: true }, + { + pubkey: withdrawReserveCollateralSupply, + isSigner: false, + isWritable: true, + }, { pubkey: obligation, isSigner: false, isWritable: true }, @@ -75,4 +83,4 @@ export const liquidateInstruction = ( programId: LENDING_PROGRAM_ID, data, }); -}; \ No newline at end of file +}; diff --git a/src/models/lending/obligation.ts b/src/models/lending/obligation.ts index d5983ad..daeaf35 100644 --- a/src/models/lending/obligation.ts +++ b/src/models/lending/obligation.ts @@ -1,4 +1,4 @@ -import { PublicKey } from "@solana/web3.js"; +import { AccountInfo, PublicKey } from "@solana/web3.js"; import BN from "bn.js"; import * as BufferLayout from "buffer-layout"; import * as Layout from "./../../utils/layout"; @@ -22,6 +22,10 @@ export const LendingObligationLayout: typeof BufferLayout.Structure = BufferLayo ] ); +export const isLendingObligation = (info: AccountInfo) => { + return info.data.length === LendingObligationLayout.span; +}; + export interface LendingObligation { lastUpdateSlot: BN; collateralAmount: BN; @@ -31,3 +35,23 @@ export interface LendingObligation { borrowReserve: PublicKey; tokenMint: PublicKey; } + +export const LendingObligationParser = ( + pubKey: PublicKey, + info: AccountInfo +) => { + const buffer = Buffer.from(info.data); + const data = LendingObligationLayout.decode(buffer); + + console.log(data); + + const details = { + pubkey: pubKey, + account: { + ...info, + }, + info: data, + }; + + return details; +}; diff --git a/src/models/lending/repay.ts b/src/models/lending/repay.ts index afdfb9d..0ecd644 100644 --- a/src/models/lending/repay.ts +++ b/src/models/lending/repay.ts @@ -1,4 +1,8 @@ -import { PublicKey, SYSVAR_CLOCK_PUBKEY, TransactionInstruction } from "@solana/web3.js"; +import { + PublicKey, + SYSVAR_CLOCK_PUBKEY, + TransactionInstruction, +} from "@solana/web3.js"; import BN from "bn.js"; import { LENDING_PROGRAM_ID, TOKEN_PROGRAM_ID } from "../../constants/ids"; import { LendingInstruction } from "./lending"; @@ -55,12 +59,16 @@ export const repayInstruction = ( { pubkey: repayReserveLiquiditySupply, isSigner: false, isWritable: true }, { pubkey: withdrawReserve, isSigner: false, isWritable: false }, - { pubkey: withdrawReserveCollateralSupply, isSigner: false, isWritable: true }, + { + pubkey: withdrawReserveCollateralSupply, + isSigner: false, + isWritable: true, + }, { pubkey: obligation, isSigner: false, isWritable: true }, { pubkey: obligationMint, isSigner: false, isWritable: true }, { pubkey: obligationInput, isSigner: false, isWritable: true }, - + { pubkey: authority, isSigner: false, isWritable: false }, { pubkey: SYSVAR_CLOCK_PUBKEY, isSigner: false, isWritable: false }, { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, @@ -70,4 +78,4 @@ export const repayInstruction = ( programId: LENDING_PROGRAM_ID, data, }); -}; \ No newline at end of file +}; diff --git a/src/routes.tsx b/src/routes.tsx index 1b7c3e3..84105e8 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -8,15 +8,16 @@ import { LendingProvider } from "./contexts/lending"; import { AppLayout } from "./components/Layout"; import { - HomeView, - DepositView, - DepositReserveView, - BorrowView, - ReserveView, - DashboardView, BorrowReserveView, - WithdrawView, + BorrowView, + DashboardView, + DepositReserveView, + DepositView, FaucetView, + HomeView, + RepayReserveView, + ReserveView, + WithdrawView, } from "./views"; export function Routes() { @@ -52,6 +53,10 @@ export function Routes() { path="/borrow/:id" children={} /> + } + /> } /> diff --git a/src/views/dashboard/index.tsx b/src/views/dashboard/index.tsx index 3065fbf..dd94ce2 100644 --- a/src/views/dashboard/index.tsx +++ b/src/views/dashboard/index.tsx @@ -1,9 +1,16 @@ import React from "react"; +import { useUserObligations } from "./../../hooks"; export const DashboardView = () => { + const { userObligations } = useUserObligations(); + return (
DASHBOARD: TODO: 1. Add deposits 2. Add obligations + + {userObligations.map(item => { + return
{item?.oblication.info.borrowAmount.toString()}
; + })}
); }; diff --git a/src/views/home/item.tsx b/src/views/home/item.tsx index 590d4dc..8a9abb1 100644 --- a/src/views/home/item.tsx +++ b/src/views/home/item.tsx @@ -20,7 +20,7 @@ export const LendingReserveItem = (props: { props.reserve.totalLiquidity.toNumber(), liquidityMint ); - + const totalBorrows = props.reserve.totalBorrows.toString(); console.log(liquidityMint); diff --git a/src/views/index.tsx b/src/views/index.tsx index b68beef..41c9247 100644 --- a/src/views/index.tsx +++ b/src/views/index.tsx @@ -7,3 +7,4 @@ export { DepositReserveView } from "./depositReserve"; export { ReserveView } from "./reserve"; export { WithdrawView } from "./withdraw"; export { FaucetView } from "./faucet"; +export { RepayReserveView } from "./repayReserve"; diff --git a/src/views/repayReserve/index.tsx b/src/views/repayReserve/index.tsx new file mode 100644 index 0000000..9734a47 --- /dev/null +++ b/src/views/repayReserve/index.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { useLendingReserve } from "../../hooks"; +import { useParams } from "react-router-dom"; + +import { RepayInput } from "../../components/RepayInput"; +import { + SideReserveOverview, + SideReserveOverviewMode, +} from "../../components/SideReserveOverview"; + +import "./style.less"; +import { LendingObligation } from "../../models"; + +export const RepayReserveView = () => { + const { id } = useParams<{ id: string }>(); + const lendingReserve = useLendingReserve(id); + const reserve = lendingReserve?.info; + + // TODO: query for lending obligation + const ob: LendingObligation = {} as any; + + if (!reserve || !lendingReserve) { + return null; + } + + return ( +
+
+ + +
+
+ ); +}; diff --git a/src/views/repayReserve/style.less b/src/views/repayReserve/style.less new file mode 100644 index 0000000..79cb019 --- /dev/null +++ b/src/views/repayReserve/style.less @@ -0,0 +1,30 @@ +.repay-reserve { + display: flex; + flex-direction: column; + flex: 1; +} + +.repay-reserve-item { + margin: 4px; +} + +.repay-reserve-container { + display: flex; + flex-wrap: wrap; + flex: 1; +} + +.repay-reserve-item-left { + flex: 60%; +} + +.repay-reserve-item-right { + flex: 30%; +} + +/* Responsive layout - makes a one column layout instead of a two-column layout */ +@media (max-width: 600px) { + .repay-reserve-item-right, .repay-reserve-item-left { + flex: 100%; + } +} \ No newline at end of file