From 30d5a87cb598f8d1ef3e3aa110457e0605cfb8df Mon Sep 17 00:00:00 2001 From: "Mr. Dummy Tester" Date: Tue, 22 Dec 2020 18:36:31 -0600 Subject: [PATCH 01/13] test --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5b48a3e..006ce11 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ ## ⚠️ Warning - + 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 From f75eba1b57022e238c32214a679f6abb0f9e50f4 Mon Sep 17 00:00:00 2001 From: "Mr. Dummy Tester" Date: Tue, 22 Dec 2020 19:27:09 -0600 Subject: [PATCH 02/13] feat: stub out margin trading tab --- src/components/Layout/index.tsx | 12 +++++++++++- src/constants/labels.ts | 1 + src/routes.tsx | 6 ++++++ src/views/index.tsx | 1 + src/views/marginTrading/index.tsx | 13 +++++++++++++ src/views/marginTrading/style.less | 1 + 6 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 src/views/marginTrading/index.tsx create mode 100644 src/views/marginTrading/style.less diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index 9f1d565..79b1275 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -9,6 +9,7 @@ import { ShoppingOutlined, HomeOutlined, RocketOutlined, + LineChartOutlined } from "@ant-design/icons"; import BasicLayout from "@ant-design/pro-layout"; @@ -104,8 +105,17 @@ export const AppLayout = (props: any) => { {LABELS.MENU_LIQUIDATE} + }> + + {LABELS.MARGIN_TRADING} + + {env !== "mainnet-beta" && ( - }> + }> } /> + } + /> } /> diff --git a/src/views/index.tsx b/src/views/index.tsx index 69f7522..51b2c53 100644 --- a/src/views/index.tsx +++ b/src/views/index.tsx @@ -10,3 +10,4 @@ export { FaucetView } from "./faucet"; export { RepayReserveView } from "./repayReserve"; export { LiquidateView } from "./liquidate"; export { LiquidateReserveView } from "./liquidateReserve"; +export { MarginTrading } from "./marginTrading"; diff --git a/src/views/marginTrading/index.tsx b/src/views/marginTrading/index.tsx new file mode 100644 index 0000000..af35ca3 --- /dev/null +++ b/src/views/marginTrading/index.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { LABELS } from "../../constants"; +import "./style.less"; +import { Card } from "antd"; + +export const MarginTrading = () => { + + return ( +
+ +
+ ); +}; diff --git a/src/views/marginTrading/style.less b/src/views/marginTrading/style.less new file mode 100644 index 0000000..614b9a6 --- /dev/null +++ b/src/views/marginTrading/style.less @@ -0,0 +1 @@ +@import '~antd/es/style/themes/default.less'; From 18ee623ef5ca9d9fa54f962c1aa9c81da6e802d3 Mon Sep 17 00:00:00 2001 From: "Mr. Dummy Tester" Date: Wed, 23 Dec 2020 12:21:58 -0600 Subject: [PATCH 03/13] feat: broad strokes just adding dummy new row type stuff and playing around with ux --- src/components/Input/numeric.tsx | 23 ++-- src/constants/labels.ts | 109 ++++++++++-------- .../marginTrading/MarginTradePosition.tsx | 94 +++++++++++++++ src/views/marginTrading/index.tsx | 31 ++++- src/views/marginTrading/style.less | 45 ++++++++ 5 files changed, 230 insertions(+), 72 deletions(-) create mode 100644 src/views/marginTrading/MarginTradePosition.tsx diff --git a/src/components/Input/numeric.tsx b/src/components/Input/numeric.tsx index 11605a2..1338a45 100644 --- a/src/components/Input/numeric.tsx +++ b/src/components/Input/numeric.tsx @@ -1,11 +1,11 @@ -import React from "react"; -import { Input } from "antd"; +import React from 'react'; +import { Input } from 'antd'; export class NumericInput extends React.Component { onChange = (e: any) => { const { value } = e.target; const reg = /^-?\d*(\.\d*)?$/; - if (reg.test(value) || value === "" || value === "-") { + if (reg.test(value) || value === '' || value === '-') { this.props.onChange(value); } }; @@ -14,26 +14,19 @@ export class NumericInput extends React.Component { onBlur = () => { const { value, onBlur, onChange } = this.props; let valueTemp = value; - if (value.charAt(value.length - 1) === "." || value === "-") { + if (value.charAt(value.length - 1) === '.' || value === '-') { valueTemp = value.slice(0, -1); } - if (value.startsWith(".") || value.startsWith("-.")) { - valueTemp = valueTemp.replace(".", "0."); + if (value.startsWith('.') || value.startsWith('-.')) { + valueTemp = valueTemp.replace('.', '0.'); } - onChange(valueTemp.replace(/0*(\d+)/, "$1")); + onChange?.(valueTemp.replace(/0*(\d+)/, '$1')); if (onBlur) { onBlur(); } }; render() { - return ( - - ); + return ; } } diff --git a/src/constants/labels.ts b/src/constants/labels.ts index f1aab4f..2fc30f9 100644 --- a/src/constants/labels.ts +++ b/src/constants/labels.ts @@ -1,56 +1,63 @@ export const LABELS = { - CONNECT_LABEL: "Connect Wallet", - GIVE_SOL: "Give me SOL", - FAUCET_INFO: - "This faucet will help you fund your accounts outside of Solana main network.", - ACCOUNT_FUNDED: "Account funded.", - REPAY_QUESTION: "How much would you like to repay?", - REPAY_ACTION: "Repay", - RESERVE_STATUS_TITLE: "Reserve Status & Configuration", + CONNECT_LABEL: 'Connect Wallet', + GIVE_SOL: 'Give me SOL', + FAUCET_INFO: 'This faucet will help you fund your accounts outside of Solana main network.', + ACCOUNT_FUNDED: 'Account funded.', + REPAY_QUESTION: 'How much would you like to repay?', + REPAY_ACTION: 'Repay', + RESERVE_STATUS_TITLE: 'Reserve Status & Configuration', AUDIT_WARNING: - "Oyster is an unaudited software project used for internal purposes at the Solana Foundation. This app is not for public use.", + 'Oyster is an unaudited software project used for internal purposes at the Solana Foundation. This app is not for public use.', FOOTER: 'This page was produced by the Solana Foundation ("SF") for internal educational and inspiration purposes only. SF does not encourage, induce or sanction the deployment, integration or use of Oyster or any similar application (including its code) in violation of applicable laws or regulations and hereby prohibits any such deployment, integration or use. Anyone using this code or a derivation thereof must comply with applicable laws and regulations when releasing related software.', - MENU_HOME: "Home", - MENU_DASHBOARD: "Dashboard", - DASHBOARD_INFO: "Connect to a wallet to view your deposits/loans.", - NO_LOANS_NO_DEPOSITS: "No loans or deposits.", - MENU_DEPOSIT: "Deposit", - MENU_BORROW: "Borrow", - MENU_LIQUIDATE: "Liquidate", - MENU_FAUCET: "Faucet", - MARGIN_TRADING: "Margin Trading", - APP_TITLE: "Oyster Lending", - CONNECT_BUTTON: "Connect", - WALLET_TOOLTIP: "Wallet public key", - SETTINGS_TOOLTIP: "Settings", - SELECT_COLLATERAL: "Select collateral", - COLLATERAL: "Collateral", - BORROW_QUESTION: "How much would you like to borrow?", - BORROW_ACTION: "Borrow", - LIQUIDATE_ACTION: "Liquidate", - LIQUIDATE_NO_LOANS: "There are no loans to liquidate.", - TABLE_TITLE_ASSET: "Asset", - TABLE_TITLE_YOUR_LOAN_BALANCE: "Loan balance", - TABLE_TITLE_LOAN_BALANCE: "Loan balance", - TABLE_TITLE_COLLATERAL_BALANCE: "Collateral", - TABLE_TITLE_DEPOSIT_BALANCE: "Your deposits", - TABLE_TITLE_APY: "APY", - TABLE_TITLE_LTV: "LTV", - TABLE_TITLE_HEALTH: "Health Factor", - TABLE_TITLE_BORROW_APY: "Borrow APY", - TABLE_TITLE_DEPOSIT_APY: "Deposit APY", - TABLE_TITLE_TOTAL_BORROWED: "Total Borrowed", - TABLE_TITLE_MARKET_SIZE: "Market Size", - TABLE_TITLE_ACTION: "Action", - TABLE_TITLE_MAX_BORROW: "Available for you", - DASHBOARD_TITLE_LOANS: "Loans", - DASHBOARD_TITLE_DEPOSITS: "Deposits", - DEPOSIT_QUESTION: "How much would you like to deposit?", - WITHDRAW_ACTION: "Withdraw", - WITHDRAW_QUESTION: "How much would you like to withdraw?", - DASHBOARD_ACTION: "Go to dashboard", - GO_BACK_ACTION: "Go back", - DEPOSIT_ACTION: "Deposit", - TOTAL_TITLE: "Total", + MENU_HOME: 'Home', + MENU_DASHBOARD: 'Dashboard', + DASHBOARD_INFO: 'Connect to a wallet to view your deposits/loans.', + NO_LOANS_NO_DEPOSITS: 'No loans or deposits.', + MENU_DEPOSIT: 'Deposit', + MENU_BORROW: 'Borrow', + MENU_LIQUIDATE: 'Liquidate', + MENU_FAUCET: 'Faucet', + MARGIN_TRADING: 'Margin Trading', + APP_TITLE: 'Oyster Lending', + CONNECT_BUTTON: 'Connect', + WALLET_TOOLTIP: 'Wallet public key', + SETTINGS_TOOLTIP: 'Settings', + SELECT_COLLATERAL: 'Select collateral', + COLLATERAL: 'Collateral', + BORROW_QUESTION: 'How much would you like to borrow?', + BORROW_ACTION: 'Borrow', + LIQUIDATE_ACTION: 'Liquidate', + LIQUIDATE_NO_LOANS: 'There are no loans to liquidate.', + TABLE_TITLE_ASSET: 'Asset', + TABLE_TITLE_YOUR_LOAN_BALANCE: 'Loan balance', + TABLE_TITLE_LOAN_BALANCE: 'Loan balance', + TABLE_TITLE_COLLATERAL_BALANCE: 'Collateral', + TABLE_TITLE_DEPOSIT_BALANCE: 'Your deposits', + TABLE_TITLE_APY: 'APY', + TABLE_TITLE_LTV: 'LTV', + TABLE_TITLE_HEALTH: 'Health Factor', + TABLE_TITLE_BORROW_APY: 'Borrow APY', + TABLE_TITLE_DEPOSIT_APY: 'Deposit APY', + TABLE_TITLE_TOTAL_BORROWED: 'Total Borrowed', + TABLE_TITLE_MARKET_SIZE: 'Market Size', + TABLE_TITLE_ACTION: 'Action', + TABLE_TITLE_MAX_BORROW: 'Available for you', + DASHBOARD_TITLE_LOANS: 'Loans', + DASHBOARD_TITLE_DEPOSITS: 'Deposits', + DEPOSIT_QUESTION: 'How much would you like to deposit?', + WITHDRAW_ACTION: 'Withdraw', + WITHDRAW_QUESTION: 'How much would you like to withdraw?', + DASHBOARD_ACTION: 'Go to dashboard', + GO_BACK_ACTION: 'Go back', + DEPOSIT_ACTION: 'Deposit', + TOTAL_TITLE: 'Total', + TRADING_TABLE_TITLE_MY_COLLATERAL: 'Chosen Collateral', + TRADING_TABLE_TITLE_DESIRED_ASSET: 'Desired Asset', + TRADING_TABLE_TITLE_MULTIPLIER: 'Leverage', + TRADING_TABLE_TITLE_ASSET_PRICE: 'Asset Price', + TRADING_TABLE_TITLE_LIQUIDATION_PRICE: 'Liquidation Price', + TRADING_TABLE_TITLE_APY: 'APY', + TRADING_TABLE_TITLE_ACTIONS: 'Action', + TRADING_ADD_POSITION: 'Add Position', }; diff --git a/src/views/marginTrading/MarginTradePosition.tsx b/src/views/marginTrading/MarginTradePosition.tsx new file mode 100644 index 0000000..c81582d --- /dev/null +++ b/src/views/marginTrading/MarginTradePosition.tsx @@ -0,0 +1,94 @@ +import { Button, Select, Slider } from 'antd'; +import React from 'react'; +import { NumericInput } from '../../components/Input/numeric'; +import { TokenIcon } from '../../components/TokenIcon'; +import tokens from '../../config/tokens.json'; +import { LABELS } from '../../constants/labels'; +const { Option } = Select; + +interface EditableAssetProps { + label: string; + itemAssetKey: string; + itemAssetValueKey: string; + setItem: (item: any) => void; + item: any; +} +function EditableAsset({ label, itemAssetKey, itemAssetValueKey, setItem, item }: EditableAssetProps) { + console.log('Now looking at', item); + if (!item[itemAssetKey]) { + return ( + + ); + } else { + return ( +
+ + +
+ ); + } +} + +export default function MarginTradePosition({ item, setItem }: { item: any; setItem?: (item: any) => void }) { + return ( +
+
+ {setItem && ( + + )} +
+
+ {setItem && ( + + )} +
+
+ +
+
123
+
123
+
123
+
+ +
+
+ ); +} diff --git a/src/views/marginTrading/index.tsx b/src/views/marginTrading/index.tsx index af35ca3..bbe4d5e 100644 --- a/src/views/marginTrading/index.tsx +++ b/src/views/marginTrading/index.tsx @@ -1,13 +1,32 @@ -import React from "react"; -import { LABELS } from "../../constants"; -import "./style.less"; -import { Card } from "antd"; +import React, { useState } from 'react'; +import { LABELS } from '../../constants'; +import './style.less'; +import { Card } from 'antd'; +import MarginTradePosition from './MarginTradePosition'; export const MarginTrading = () => { + const [newPosition, setNewPosition] = useState({ id: null }); + const positions: any[] = []; return ( -
- +
+
+ +
+
{LABELS.TRADING_TABLE_TITLE_MY_COLLATERAL}
+
{LABELS.TRADING_TABLE_TITLE_DESIRED_ASSET}
+
{LABELS.TRADING_TABLE_TITLE_MULTIPLIER}
+
{LABELS.TRADING_TABLE_TITLE_ASSET_PRICE}
+
{LABELS.TRADING_TABLE_TITLE_LIQUIDATION_PRICE}
+
{LABELS.TRADING_TABLE_TITLE_APY}
+
{LABELS.TRADING_TABLE_TITLE_ACTIONS}
+
+ + {positions.map((item) => ( + + ))} +
+
); }; diff --git a/src/views/marginTrading/style.less b/src/views/marginTrading/style.less index 614b9a6..f32f4d3 100644 --- a/src/views/marginTrading/style.less +++ b/src/views/marginTrading/style.less @@ -1 +1,46 @@ @import '~antd/es/style/themes/default.less'; +.trading-item { + display: flex; + justify-content: space-between; + align-items: center; + color: @text-color; + + & > :nth-child(n) { + flex: 20%; + text-align: left; + margin: 10px 0px; + } + + & > :first-child { + flex: 300px; + } + + & > :nth-child(2) { + flex: 300px; + } + + border-bottom: 1px solid #eee; +} + +.trading-header { + justify-content: space-between; + & > div { + flex: 20%; + margin: 10px 0px; + text-align: left; + } +} + +.trading-info { + display: flex; + align-self: center; + justify-content: center; + flex: 1; +} + +.trading-container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + flex: 1; +} From 61cf44e95a0c4ed01c2c1a105269670f3be515be Mon Sep 17 00:00:00 2001 From: "Mr. Dummy Tester" Date: Wed, 23 Dec 2020 15:21:58 -0600 Subject: [PATCH 04/13] feat: First screen attempt: Try to come up with a clever way to visualize your margin order and just get something up there. Next up, move to a better order flow. --- .../marginTrading/MarginTradePosition.tsx | 67 +++++++---- src/views/marginTrading/index.tsx | 110 +++++++++++++++++- 2 files changed, 154 insertions(+), 23 deletions(-) diff --git a/src/views/marginTrading/MarginTradePosition.tsx b/src/views/marginTrading/MarginTradePosition.tsx index c81582d..8b30ed1 100644 --- a/src/views/marginTrading/MarginTradePosition.tsx +++ b/src/views/marginTrading/MarginTradePosition.tsx @@ -1,28 +1,29 @@ import { Button, Select, Slider } from 'antd'; import React from 'react'; +import { IPosition } from '.'; import { NumericInput } from '../../components/Input/numeric'; import { TokenIcon } from '../../components/TokenIcon'; import tokens from '../../config/tokens.json'; import { LABELS } from '../../constants/labels'; const { Option } = Select; -interface EditableAssetProps { +interface IEditableAssetProps { label: string; - itemAssetKey: string; - itemAssetValueKey: string; + assetKey: string; setItem: (item: any) => void; item: any; } -function EditableAsset({ label, itemAssetKey, itemAssetValueKey, setItem, item }: EditableAssetProps) { - console.log('Now looking at', item); - if (!item[itemAssetKey]) { +function EditableAsset({ label, assetKey, setItem, item }: IEditableAssetProps) { + if (!item[assetKey]?.type) { return ( setItem({ ...item, collateral: tokens.find((t) => t.mintAddress === v) })} + > + {tokens.map((token) => ( + + ))} + )}
@@ -72,14 +85,26 @@ export default function MarginTradePosition({ item, setItem }: { item: any; setI )}
- + {setItem && ( + { + setItem({ ...item, leverage: v }); + }} + /> + )}
123
123
diff --git a/src/views/marginTrading/index.tsx b/src/views/marginTrading/index.tsx index bbe4d5e..d9f855e 100644 --- a/src/views/marginTrading/index.tsx +++ b/src/views/marginTrading/index.tsx @@ -1,11 +1,116 @@ import React, { useState } from 'react'; import { LABELS } from '../../constants'; import './style.less'; -import { Card } from 'antd'; +import { Card, Progress, Slider, Statistic } from 'antd'; import MarginTradePosition from './MarginTradePosition'; +import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'; +export interface IToken { + mintAddress: string; + tokenName: string; + tokenSymbol: string; +} + +export interface IPosition { + id?: number | null; + leverage: number; + collateral?: IToken; + asset?: { + type: IToken; + value: number; + }; +} + +export function Breakdown({ item }: { item: IPosition }) { + let myPart = (item.asset?.value || 0) / item.leverage; + const brokeragePart = (item.asset?.value || 0) - myPart; + const brokerageColor = 'brown'; + const myColor = 'blue'; + const gains = 'green'; + const losses = 'red'; + + const [myGain, setMyGain] = useState(0); + const profitPart = (myPart + brokeragePart) * (myGain / 100); + let progressBar = null; + if (profitPart > 0) { + // normalize... + const total = profitPart + myPart + brokeragePart; + progressBar = ( + + ); + } else { + // now, we're eating away your profit... + myPart += profitPart; // profit is negative + const total = myPart + brokeragePart; + if (myPart < 0) { + progressBar =

Your position has been liquidated at this price swing.

; + } else + progressBar = ( + + ); + } + + return ( +
+ { + setMyGain(v); + }} + style={{ marginBottom: '20px' }} + /> +
+ + + + + + + + 0 ? gains : losses }} + suffix={item.asset?.type.tokenSymbol} + prefix={profitPart > 0 ? : } + /> + +
+ {progressBar} +
+ ); +} export const MarginTrading = () => { - const [newPosition, setNewPosition] = useState({ id: null }); + const [newPosition, setNewPosition] = useState({ id: null, leverage: 1 }); const positions: any[] = []; return ( @@ -22,6 +127,7 @@ export const MarginTrading = () => {
{LABELS.TRADING_TABLE_TITLE_ACTIONS}
+ {positions.map((item) => ( ))} From 199253e52f0c0fae9d46ffcd47c460afc63fba86 Mon Sep 17 00:00:00 2001 From: "Mr. Dummy Tester" Date: Thu, 24 Dec 2020 10:10:34 -0600 Subject: [PATCH 05/13] feat: back to baseline but in a new flow. about to brign in some swap code to get leverage limits from amm. --- src/components/CollateralSelector/index.tsx | 47 +++--- src/constants/labels.ts | 3 + src/routes.tsx | 82 ++++------ src/views/borrow/item.tsx | 39 +++-- .../marginTrading/MarginTradePosition.tsx | 119 -------------- src/views/marginTrading/index.tsx | 150 +++--------------- src/views/marginTrading/item.tsx | 43 +++++ src/views/marginTrading/itemStyle.less | 32 ++++ .../marginTrading/newPosition/Breakdown.tsx | 93 +++++++++++ .../newPosition/EditableAsset.tsx | 56 +++++++ .../newPosition/NewPositionForm.tsx | 108 +++++++++++++ src/views/marginTrading/newPosition/index.tsx | 46 ++++++ .../marginTrading/newPosition/interfaces.tsx | 15 ++ .../marginTrading/newPosition/style.less | 32 ++++ 14 files changed, 511 insertions(+), 354 deletions(-) delete mode 100644 src/views/marginTrading/MarginTradePosition.tsx create mode 100644 src/views/marginTrading/item.tsx create mode 100644 src/views/marginTrading/itemStyle.less create mode 100644 src/views/marginTrading/newPosition/Breakdown.tsx create mode 100644 src/views/marginTrading/newPosition/EditableAsset.tsx create mode 100644 src/views/marginTrading/newPosition/NewPositionForm.tsx create mode 100644 src/views/marginTrading/newPosition/index.tsx create mode 100644 src/views/marginTrading/newPosition/interfaces.tsx create mode 100644 src/views/marginTrading/newPosition/style.less diff --git a/src/components/CollateralSelector/index.tsx b/src/components/CollateralSelector/index.tsx index 64d0f2f..2a727bc 100644 --- a/src/components/CollateralSelector/index.tsx +++ b/src/components/CollateralSelector/index.tsx @@ -1,11 +1,11 @@ -import React from "react"; -import { useLendingReserves } from "../../hooks"; -import { LendingMarket, LendingReserve } from "../../models"; -import { TokenIcon } from "../TokenIcon"; -import { getTokenName } from "../../utils/utils"; -import { Select } from "antd"; -import { useConnectionConfig } from "../../contexts/connection"; -import { cache, ParsedAccount } from "../../contexts/accounts"; +import React from 'react'; +import { useLendingReserves } from '../../hooks'; +import { LendingMarket, LendingReserve } from '../../models'; +import { TokenIcon } from '../TokenIcon'; +import { getTokenName } from '../../utils/utils'; +import { Select } from 'antd'; +import { useConnectionConfig } from '../../contexts/connection'; +import { cache, ParsedAccount } from '../../contexts/accounts'; const { Option } = Select; @@ -18,19 +18,17 @@ export const CollateralSelector = (props: { const { reserveAccounts } = useLendingReserves(); const { tokenMap } = useConnectionConfig(); - const market = cache.get(props.reserve.lendingMarket) as ParsedAccount< - LendingMarket - >; - const onlyQuoteAllowed = !props.reserve?.liquidityMint?.equals( - market?.info?.quoteMint - ); + const market = cache.get(props.reserve.lendingMarket) as ParsedAccount; + if (!market) return null; + + const onlyQuoteAllowed = !props.reserve?.liquidityMint?.equals(market?.info?.quoteMint); return ( - setItem({ ...item, [assetKey]: { ...(item[assetKey] || {}), type: tokens.find((t) => t.mintAddress === v) } }) - } - > - {tokens.map((token) => ( - - ))} - - ); - } else { - return ( -
- { - setItem({ ...item, [assetKey]: { ...(item[assetKey] || {}), value: v } }); - }} - placeholder='0.00' - /> - -
- ); - } -} - -export default function MarginTradePosition({ item, setItem }: { item: IPosition; setItem?: (item: any) => void }) { - return ( -
-
- {setItem && ( - - )} -
-
- {setItem && ( - - )} -
-
- {setItem && ( - { - setItem({ ...item, leverage: v }); - }} - /> - )} -
-
123
-
123
-
123
-
- -
-
- ); -} diff --git a/src/views/marginTrading/index.tsx b/src/views/marginTrading/index.tsx index d9f855e..1fe2520 100644 --- a/src/views/marginTrading/index.tsx +++ b/src/views/marginTrading/index.tsx @@ -1,138 +1,26 @@ -import React, { useState } from 'react'; +import React from 'react'; import { LABELS } from '../../constants'; -import './style.less'; -import { Card, Progress, Slider, Statistic } from 'antd'; -import MarginTradePosition from './MarginTradePosition'; -import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'; +import './itemStyle.less'; +import { Card } from 'antd'; +import { useLendingReserves } from '../../hooks/useLendingReserves'; +import { MarginTradeItem } from './item'; -export interface IToken { - mintAddress: string; - tokenName: string; - tokenSymbol: string; -} - -export interface IPosition { - id?: number | null; - leverage: number; - collateral?: IToken; - asset?: { - type: IToken; - value: number; - }; -} - -export function Breakdown({ item }: { item: IPosition }) { - let myPart = (item.asset?.value || 0) / item.leverage; - const brokeragePart = (item.asset?.value || 0) - myPart; - const brokerageColor = 'brown'; - const myColor = 'blue'; - const gains = 'green'; - const losses = 'red'; - - const [myGain, setMyGain] = useState(0); - const profitPart = (myPart + brokeragePart) * (myGain / 100); - let progressBar = null; - if (profitPart > 0) { - // normalize... - const total = profitPart + myPart + brokeragePart; - progressBar = ( - - ); - } else { - // now, we're eating away your profit... - myPart += profitPart; // profit is negative - const total = myPart + brokeragePart; - if (myPart < 0) { - progressBar =

Your position has been liquidated at this price swing.

; - } else - progressBar = ( - - ); - } - - return ( -
- { - setMyGain(v); - }} - style={{ marginBottom: '20px' }} - /> -
- - - - - - - - 0 ? gains : losses }} - suffix={item.asset?.type.tokenSymbol} - prefix={profitPart > 0 ? : } - /> - -
- {progressBar} -
- ); -} export const MarginTrading = () => { - const [newPosition, setNewPosition] = useState({ id: null, leverage: 1 }); - - const positions: any[] = []; + const { reserveAccounts } = useLendingReserves(); return ( -
-
- -
-
{LABELS.TRADING_TABLE_TITLE_MY_COLLATERAL}
-
{LABELS.TRADING_TABLE_TITLE_DESIRED_ASSET}
-
{LABELS.TRADING_TABLE_TITLE_MULTIPLIER}
-
{LABELS.TRADING_TABLE_TITLE_ASSET_PRICE}
-
{LABELS.TRADING_TABLE_TITLE_LIQUIDATION_PRICE}
-
{LABELS.TRADING_TABLE_TITLE_APY}
-
{LABELS.TRADING_TABLE_TITLE_ACTIONS}
-
- - - {positions.map((item) => ( - - ))} -
-
+
+ +
+
{LABELS.TABLE_TITLE_ASSET}
+
Serum Dex Price
+
{LABELS.TABLE_TITLE_BUYING_POWER}
+
{LABELS.TABLE_TITLE_APY}
+
+
+ {reserveAccounts.map((account) => ( + + ))} +
); }; diff --git a/src/views/marginTrading/item.tsx b/src/views/marginTrading/item.tsx new file mode 100644 index 0000000..4e2035e --- /dev/null +++ b/src/views/marginTrading/item.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { useTokenName } from '../../hooks'; +import { calculateBorrowAPY, LendingReserve } from '../../models/lending'; +import { TokenIcon } from '../../components/TokenIcon'; +import { formatNumber, formatPct } from '../../utils/utils'; +import { Button } from 'antd'; +import { Link } from 'react-router-dom'; +import { PublicKey } from '@solana/web3.js'; +import { LABELS } from '../../constants'; +import { useMidPriceInUSD } from '../../contexts/market'; + +export const MarginTradeItem = (props: { reserve: LendingReserve; address: PublicKey }) => { + const name = useTokenName(props.reserve.liquidityMint); + const price = useMidPriceInUSD(props.reserve.liquidityMint.toBase58()).price; + + const apr = calculateBorrowAPY(props.reserve); + + return ( + +
+ + + {name} + +
${formatNumber.format(price)}
+
+
+
+ {formatNumber.format(200)} {name} +
+
${formatNumber.format(300)}
+
+
+
{formatPct.format(apr)}
+
+ +
+
+ + ); +}; diff --git a/src/views/marginTrading/itemStyle.less b/src/views/marginTrading/itemStyle.less new file mode 100644 index 0000000..4d15206 --- /dev/null +++ b/src/views/marginTrading/itemStyle.less @@ -0,0 +1,32 @@ +@import '~antd/es/style/themes/default.less'; + +.choose-margin-item { + display: flex; + justify-content: space-between; + align-items: center; + color: @text-color; + + & > :nth-child(n) { + flex: 20%; + text-align: right; + margin: 10px 0px; + } + + & > :first-child { + flex: 80px; + } + + border-bottom: 1px solid #eee; +} + +.choose-margin-header { + & > div { + flex: 20%; + text-align: right; + } + + & > :first-child { + text-align: left; + flex: 80px; + } +} diff --git a/src/views/marginTrading/newPosition/Breakdown.tsx b/src/views/marginTrading/newPosition/Breakdown.tsx new file mode 100644 index 0000000..0eed231 --- /dev/null +++ b/src/views/marginTrading/newPosition/Breakdown.tsx @@ -0,0 +1,93 @@ +import { Progress, Slider, Card, Statistic } from 'antd'; +import React, { useState } from 'react'; +import { Position } from './interfaces'; +import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'; + +export function Breakdown({ item }: { item: Position }) { + let myPart = (item.asset?.value || 0) / item.leverage; + const brokeragePart = (item.asset?.value || 0) - myPart; + const brokerageColor = 'brown'; + const myColor = 'blue'; + const gains = 'green'; + const losses = 'red'; + + const [myGain, setMyGain] = useState(0); + const profitPart = (myPart + brokeragePart) * (myGain / 100); + let progressBar = null; + if (profitPart > 0) { + // normalize... + const total = profitPart + myPart + brokeragePart; + progressBar = ( + + ); + } else { + // now, we're eating away your profit... + myPart += profitPart; // profit is negative + const total = myPart + brokeragePart; + if (myPart < 0) { + progressBar =

Your position has been liquidated at this price swing.

; + } else + progressBar = ( + + ); + } + + return ( +
+ { + setMyGain(v); + }} + style={{ marginBottom: '20px' }} + /> +
+ + + + + + + + 0 ? gains : losses }} + suffix={item.asset?.type?.tokenSymbol} + prefix={profitPart > 0 ? : } + /> + +
+ {progressBar} +
+ ); +} diff --git a/src/views/marginTrading/newPosition/EditableAsset.tsx b/src/views/marginTrading/newPosition/EditableAsset.tsx new file mode 100644 index 0000000..d049ba8 --- /dev/null +++ b/src/views/marginTrading/newPosition/EditableAsset.tsx @@ -0,0 +1,56 @@ +import { Select } from 'antd'; +import React from 'react'; +import { NumericInput } from '../../../components/Input/numeric'; +import { TokenIcon } from '../../../components/TokenIcon'; +import tokens from '../../../config/tokens.json'; + +const { Option } = Select; +interface EditableAssetProps { + label: string; + assetKey: string; + setItem: (item: any) => void; + item: any; +} +export default function EditableAsset({ label, assetKey, setItem, item }: EditableAssetProps) { + if (!item[assetKey]?.type) { + return ( + + ); + } else { + return ( +
+ { + setItem({ ...item, [assetKey]: { ...(item[assetKey] || {}), value: v } }); + }} + placeholder='0.00' + /> + +
+ ); + } +} diff --git a/src/views/marginTrading/newPosition/NewPositionForm.tsx b/src/views/marginTrading/newPosition/NewPositionForm.tsx new file mode 100644 index 0000000..c628c3d --- /dev/null +++ b/src/views/marginTrading/newPosition/NewPositionForm.tsx @@ -0,0 +1,108 @@ +import { Button, Card, Radio } from 'antd'; +import React, { useState } from 'react'; +import { ActionConfirmation } from '../../../components/ActionConfirmation'; +import { NumericInput } from '../../../components/Input/numeric'; +import { TokenIcon } from '../../../components/TokenIcon'; +import { LABELS } from '../../../constants'; +import { cache, ParsedAccount } from '../../../contexts/accounts'; +import { LendingReserve, LendingReserveParser } from '../../../models/lending/reserve'; +import { Position } from './interfaces'; +import tokens from '../../../config/tokens.json'; +import { CollateralSelector } from '../../../components/CollateralSelector'; +import { Breakdown } from './Breakdown'; + +interface NewPositionFormProps { + lendingReserve: ParsedAccount; + newPosition: Position; + setNewPosition: (pos: Position) => void; +} + +export default function NewPositionForm({ lendingReserve, newPosition, setNewPosition }: NewPositionFormProps) { + const bodyStyle: React.CSSProperties = { + display: 'flex', + flex: 1, + justifyContent: 'center', + alignItems: 'center', + height: '100%', + }; + const [showConfirmation, setShowConfirmation] = useState(false); + return ( + + {showConfirmation ? ( + setShowConfirmation(false)} /> + ) : ( +
+
{LABELS.SELECT_COLLATERAL}
+ { + const id: string = cache.byParser(LendingReserveParser).find((acc) => acc === key) || ''; + const parser = cache.get(id) as ParsedAccount; + const tokenMint = parser.info.collateralMint.toBase58(); + setNewPosition({ ...newPosition, collateral: tokens.find((t) => t.mintAddress === tokenMint) }); + }} + /> + +
{LABELS.MARGIN_TRADE_QUESTION}
+
+ + { + setNewPosition({ ...newPosition, asset: { ...newPosition.asset, value: v } }); + }} + placeholder='0.00' + /> +
{newPosition.asset.type?.tokenSymbol}
+
+ +
+ { + setNewPosition({ ...newPosition, leverage: e.target.value }); + }} + > + 1x + 2x + 3x + 4x + 5x + + { + setNewPosition({ ...newPosition, leverage }); + }} + /> +
+
+ +
+ +
+ )} +
+ ); +} diff --git a/src/views/marginTrading/newPosition/index.tsx b/src/views/marginTrading/newPosition/index.tsx new file mode 100644 index 0000000..dbc1669 --- /dev/null +++ b/src/views/marginTrading/newPosition/index.tsx @@ -0,0 +1,46 @@ +import React, { useState } from 'react'; +import { useLendingReserve, useTokenName } from '../../../hooks'; +import { useParams } from 'react-router-dom'; +import './style.less'; +import tokens from '../../../config/tokens.json'; + +import { SideReserveOverview, SideReserveOverviewMode } from '../../../components/SideReserveOverview'; +import NewPositionForm from './NewPositionForm'; +import { Position } from './interfaces'; +import { useEffect } from 'react'; + +export const NewPosition = () => { + const { id } = useParams<{ id: string }>(); + const lendingReserve = useLendingReserve(id); + const [newPosition, setNewPosition] = useState({ + id: null, + leverage: 1, + asset: { value: 0 }, + }); + + const assetTokenType = tokens.find((t) => t.mintAddress === lendingReserve?.info?.liquidityMint?.toBase58()); + if (!lendingReserve) { + return null; + } + + if (!assetTokenType) { + return null; + } else { + if (newPosition.asset.type != assetTokenType) { + setNewPosition({ ...newPosition, asset: { value: newPosition.asset.value, type: assetTokenType } }); + } + } + + return ( +
+
+ + +
+
+ ); +}; diff --git a/src/views/marginTrading/newPosition/interfaces.tsx b/src/views/marginTrading/newPosition/interfaces.tsx new file mode 100644 index 0000000..5acb301 --- /dev/null +++ b/src/views/marginTrading/newPosition/interfaces.tsx @@ -0,0 +1,15 @@ +export interface Token { + mintAddress: string; + tokenName: string; + tokenSymbol: string; +} + +export interface Position { + id?: number | null; + leverage: number; + collateral?: Token; + asset: { + type?: Token; + value: number; + }; +} diff --git a/src/views/marginTrading/newPosition/style.less b/src/views/marginTrading/newPosition/style.less new file mode 100644 index 0000000..3aff82b --- /dev/null +++ b/src/views/marginTrading/newPosition/style.less @@ -0,0 +1,32 @@ +.new-position { + display: flex; + flex-direction: column; + flex: 1; +} + +.new-position-item { + margin: 4px; +} + +.new-position-container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + flex: 1; +} + +.new-position-item-left { + flex: 60%; +} + +.new-position-item-right { + flex: 30%; +} + +/* Responsive layout - makes a one column layout instead of a two-column layout */ +@media (max-width: 600px) { + .new-position-item-right, + .new-position-item-left { + flex: 100%; + } +} From 9676e8838062b0cb9418785c29c29918220e9781 Mon Sep 17 00:00:00 2001 From: "Mr. Dummy Tester" Date: Thu, 24 Dec 2020 12:02:58 -0600 Subject: [PATCH 06/13] feat: remove unneeded file --- .../newPosition/EditableAsset.tsx | 56 ------------------- 1 file changed, 56 deletions(-) delete mode 100644 src/views/marginTrading/newPosition/EditableAsset.tsx diff --git a/src/views/marginTrading/newPosition/EditableAsset.tsx b/src/views/marginTrading/newPosition/EditableAsset.tsx deleted file mode 100644 index d049ba8..0000000 --- a/src/views/marginTrading/newPosition/EditableAsset.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Select } from 'antd'; -import React from 'react'; -import { NumericInput } from '../../../components/Input/numeric'; -import { TokenIcon } from '../../../components/TokenIcon'; -import tokens from '../../../config/tokens.json'; - -const { Option } = Select; -interface EditableAssetProps { - label: string; - assetKey: string; - setItem: (item: any) => void; - item: any; -} -export default function EditableAsset({ label, assetKey, setItem, item }: EditableAssetProps) { - if (!item[assetKey]?.type) { - return ( - - ); - } else { - return ( -
- { - setItem({ ...item, [assetKey]: { ...(item[assetKey] || {}), value: v } }); - }} - placeholder='0.00' - /> - -
- ); - } -} From 0b0e5773c2cc18a9d3ad82a835ce9cddf14f15db Mon Sep 17 00:00:00 2001 From: "Mr. Dummy Tester" Date: Fri, 25 Dec 2020 13:12:17 -0600 Subject: [PATCH 07/13] feat: Intermediate commit where i am debugging a merge of swap --- src/actions/account.ts | 75 +- src/actions/borrow.tsx | 129 +- src/actions/deposit.tsx | 67 +- src/actions/liquidate.tsx | 76 +- src/actions/repay.tsx | 56 +- src/actions/withdraw.tsx | 57 +- src/constants/ids.tsx | 28 - src/constants/index.tsx | 7 +- src/constants/labels.ts | 3 + src/contexts/accounts.tsx | 235 ++-- src/contexts/connection.tsx | 101 +- src/contexts/lending.tsx | 90 +- src/contexts/market.tsx | 317 ++++- src/models/airdrops.ts | 11 + src/models/index.ts | 8 +- src/models/lending/borrow.ts | 40 +- src/models/lending/deposit.ts | 30 +- src/models/lending/liquidate.ts | 21 +- src/models/lending/repay.ts | 21 +- src/models/lending/reserve.ts | 130 +- src/models/lending/withdraw.ts | 21 +- src/models/pool.ts | 47 + src/models/tokenSwap.ts | 438 ++++++ src/utils/eventEmitter.ts | 18 +- src/utils/ids.ts | 96 ++ src/utils/pools.ts | 1181 +++++++++++++++++ src/utils/utils.ts | 105 +- .../marginTrading/newPosition/Breakdown.tsx | 8 +- .../newPosition/NewPositionForm.tsx | 79 +- src/views/marginTrading/newPosition/index.tsx | 9 +- .../marginTrading/newPosition/interfaces.tsx | 8 +- 31 files changed, 2620 insertions(+), 892 deletions(-) delete mode 100644 src/constants/ids.tsx create mode 100644 src/models/airdrops.ts create mode 100644 src/models/pool.ts create mode 100644 src/models/tokenSwap.ts create mode 100644 src/utils/ids.ts create mode 100644 src/utils/pools.ts diff --git a/src/actions/account.ts b/src/actions/account.ts index 1178dc3..00a9d6c 100644 --- a/src/actions/account.ts +++ b/src/actions/account.ts @@ -1,17 +1,8 @@ -import { AccountLayout, MintLayout, Token } from "@solana/spl-token"; -import { - Account, - PublicKey, - SystemProgram, - TransactionInstruction, -} from "@solana/web3.js"; -import { - LENDING_PROGRAM_ID, - TOKEN_PROGRAM_ID, - WRAPPED_SOL_MINT, -} from "../constants/ids"; -import { LendingObligationLayout, TokenAccount } from "../models"; -import { cache, TokenAccountParser } from "./../contexts/accounts"; +import { AccountLayout, MintLayout, Token } from '@solana/spl-token'; +import { Account, PublicKey, SystemProgram, TransactionInstruction } from '@solana/web3.js'; +import { LENDING_PROGRAM_ID, TOKEN_PROGRAM_ID, WRAPPED_SOL_MINT } from '../utils/ids'; +import { LendingObligationLayout, TokenAccount } from '../models'; +import { cache, TokenAccountParser } from './../contexts/accounts'; export function ensureSplAccount( instructions: TransactionInstruction[], @@ -25,31 +16,11 @@ export function ensureSplAccount( return toCheck.pubkey; } - const account = createUninitializedAccount( - instructions, - payer, - amount, - signers - ); + const account = createUninitializedAccount(instructions, payer, amount, signers); - instructions.push( - Token.createInitAccountInstruction( - TOKEN_PROGRAM_ID, - WRAPPED_SOL_MINT, - account, - payer - ) - ); + instructions.push(Token.createInitAccountInstruction(TOKEN_PROGRAM_ID, WRAPPED_SOL_MINT, account, payer)); - cleanupInstructions.push( - Token.createCloseAccountInstruction( - TOKEN_PROGRAM_ID, - account, - payer, - payer, - [] - ) - ); + cleanupInstructions.push(Token.createCloseAccountInstruction(TOKEN_PROGRAM_ID, account, payer, payer, [])); return account; } @@ -153,16 +124,9 @@ export function createTokenAccount( owner: PublicKey, signers: Account[] ) { - const account = createUninitializedAccount( - instructions, - payer, - accountRentExempt, - signers - ); + const account = createUninitializedAccount(instructions, payer, accountRentExempt, signers); - instructions.push( - Token.createInitAccountInstruction(TOKEN_PROGRAM_ID, mint, account, owner) - ); + instructions.push(Token.createInitAccountInstruction(TOKEN_PROGRAM_ID, mint, account, owner)); return account; } @@ -196,25 +160,10 @@ export function findOrCreateAccountByMint( toAccount = account.pubkey; } else { // creating depositor pool account - toAccount = createTokenAccount( - instructions, - payer, - accountRentExempt, - mint, - owner, - signers - ); + toAccount = createTokenAccount(instructions, payer, accountRentExempt, mint, owner, signers); if (isWrappedSol) { - cleanupInstructions.push( - Token.createCloseAccountInstruction( - TOKEN_PROGRAM_ID, - toAccount, - payer, - payer, - [] - ) - ); + cleanupInstructions.push(Token.createCloseAccountInstruction(TOKEN_PROGRAM_ID, toAccount, payer, payer, [])); } } diff --git a/src/actions/borrow.tsx b/src/actions/borrow.tsx index 6f3018c..a83c012 100644 --- a/src/actions/borrow.tsx +++ b/src/actions/borrow.tsx @@ -1,14 +1,9 @@ -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 { AccountLayout, MintInfo, MintLayout, Token } from "@solana/spl-token"; -import { LENDING_PROGRAM_ID, TOKEN_PROGRAM_ID } from "../constants/ids"; +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 { AccountLayout, MintInfo, MintLayout, Token } from '@solana/spl-token'; +import { LENDING_PROGRAM_ID, TOKEN_PROGRAM_ID } from '../utils/ids'; import { createTempMemoryAccount, createUninitializedAccount, @@ -16,8 +11,8 @@ import { createUninitializedObligation, ensureSplAccount, findOrCreateAccountByMint, -} from "./account"; -import { cache, MintParser, ParsedAccount } from "../contexts/accounts"; +} from './account'; +import { cache, MintParser, ParsedAccount } from '../contexts/accounts'; import { TokenAccount, LendingObligationLayout, @@ -25,8 +20,8 @@ import { LendingMarket, BorrowAmountType, LendingObligation, -} from "../models"; -import { toLamports } from "../utils/utils"; +} from '../models'; +import { toLamports } from '../utils/utils'; export const borrow = async ( connection: Connection, @@ -45,47 +40,38 @@ export const borrow = async ( obligationAccount?: PublicKey ) => { notify({ - message: "Borrowing funds...", - description: "Please review transactions to approve.", - type: "warn", + message: 'Borrowing funds...', + description: 'Please review transactions to approve.', + type: 'warn', }); let signers: Account[] = []; let instructions: TransactionInstruction[] = []; let cleanupInstructions: TransactionInstruction[] = []; - const accountRentExempt = await connection.getMinimumBalanceForRentExemption( - AccountLayout.span - ); + const accountRentExempt = await connection.getMinimumBalanceForRentExemption(AccountLayout.span); const obligation = existingObligation ? existingObligation.pubkey : createUninitializedObligation( - instructions, - wallet.publicKey, - await connection.getMinimumBalanceForRentExemption( - LendingObligationLayout.span - ), - signers - ); + instructions, + wallet.publicKey, + await connection.getMinimumBalanceForRentExemption(LendingObligationLayout.span), + signers + ); const obligationMint = existingObligation ? existingObligation.info.tokenMint : createUninitializedMint( - instructions, - wallet.publicKey, - await connection.getMinimumBalanceForRentExemption(MintLayout.span), - signers - ); + instructions, + wallet.publicKey, + await connection.getMinimumBalanceForRentExemption(MintLayout.span), + signers + ); const obligationTokenOutput = obligationAccount ? obligationAccount - : createUninitializedAccount( - instructions, - wallet.publicKey, - accountRentExempt, - signers - ); + : createUninitializedAccount(instructions, wallet.publicKey, accountRentExempt, signers); let toAccount = await findOrCreateAccountByMint( wallet.publicKey, @@ -99,21 +85,19 @@ export const borrow = async ( if (instructions.length > 0) { // create all accounts in one transaction - let tx = await sendTransaction(connection, wallet, instructions, [ - ...signers, - ]); + let tx = await sendTransaction(connection, wallet, instructions, [...signers]); notify({ - message: "Obligation accounts created", + message: 'Obligation accounts created', description: `Transaction ${tx}`, - type: "success", + type: 'success', }); } notify({ - message: "Borrowing funds...", - description: "Please review transactions to approve.", - type: "warn", + message: 'Borrowing funds...', + description: 'Please review transactions to approve.', + type: 'warn', }); signers = []; @@ -134,19 +118,15 @@ export const borrow = async ( fromLamports = approvedAmount - accountRentExempt; - const mint = (await cache.query( - connection, - borrowReserve.info.liquidityMint, - MintParser - )) as ParsedAccount; + const mint = (await cache.query(connection, borrowReserve.info.liquidityMint, MintParser)) as ParsedAccount< + MintInfo + >; amountLamports = toLamports(amount, mint?.info); } else if (amountType === BorrowAmountType.CollateralDepositAmount) { - const mint = (await cache.query( - connection, - depositReserve.info.collateralMint, - MintParser - )) as ParsedAccount; + const mint = (await cache.query(connection, depositReserve.info.collateralMint, MintParser)) as ParsedAccount< + MintInfo + >; amountLamports = toLamports(amount, mint?.info); fromLamports = amountLamports; } @@ -162,14 +142,7 @@ export const borrow = async ( // create approval for transfer transactions instructions.push( - Token.createApproveInstruction( - TOKEN_PROGRAM_ID, - fromAccount, - authority, - wallet.publicKey, - [], - fromLamports - ) + Token.createApproveInstruction(TOKEN_PROGRAM_ID, fromAccount, authority, wallet.publicKey, [], fromLamports) ); const dexMarketAddress = borrowReserve.info.dexMarketOption @@ -181,20 +154,12 @@ export const borrow = async ( throw new Error(`Dex market doesn't exist.`); } - const market = cache.get(depositReserve.info.lendingMarket) as ParsedAccount< - LendingMarket - >; - const dexOrderBookSide = market.info.quoteMint.equals( - depositReserve.info.liquidityMint - ) + const market = cache.get(depositReserve.info.lendingMarket) as ParsedAccount; + const dexOrderBookSide = market.info.quoteMint.equals(depositReserve.info.liquidityMint) ? dexMarket?.info.bids : dexMarket?.info.asks; - const memory = createTempMemoryAccount( - instructions, - wallet.publicKey, - signers - ); + const memory = createTempMemoryAccount(instructions, wallet.publicKey, signers); // deposit instructions.push( @@ -222,17 +187,11 @@ export const borrow = async ( ) ); try { - let tx = await sendTransaction( - connection, - wallet, - instructions.concat(cleanupInstructions), - signers, - true - ); + let tx = await sendTransaction(connection, wallet, instructions.concat(cleanupInstructions), signers, true); notify({ - message: "Funds borrowed.", - type: "success", + message: 'Funds borrowed.', + type: 'success', description: `Transaction - ${tx}`, }); } catch { diff --git a/src/actions/deposit.tsx b/src/actions/deposit.tsx index 672984d..b00fb5f 100644 --- a/src/actions/deposit.tsx +++ b/src/actions/deposit.tsx @@ -1,24 +1,11 @@ -import { - Account, - Connection, - PublicKey, - TransactionInstruction, -} from "@solana/web3.js"; -import { sendTransaction } from "../contexts/connection"; -import { notify } from "../utils/notifications"; -import { - depositInstruction, - initReserveInstruction, - LendingReserve, -} from "./../models/lending"; -import { AccountLayout, Token } from "@solana/spl-token"; -import { LENDING_PROGRAM_ID, TOKEN_PROGRAM_ID } from "../constants/ids"; -import { - createUninitializedAccount, - ensureSplAccount, - findOrCreateAccountByMint, -} from "./account"; -import { TokenAccount } from "../models"; +import { Account, Connection, PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { sendTransaction } from '../contexts/connection'; +import { notify } from '../utils/notifications'; +import { depositInstruction, initReserveInstruction, LendingReserve } from './../models/lending'; +import { AccountLayout, Token } from '@solana/spl-token'; +import { LENDING_PROGRAM_ID, TOKEN_PROGRAM_ID } from '../utils/ids'; +import { createUninitializedAccount, ensureSplAccount, findOrCreateAccountByMint } from './account'; +import { TokenAccount } from '../models'; export const deposit = async ( from: TokenAccount, @@ -29,9 +16,9 @@ export const deposit = async ( wallet: any ) => { notify({ - message: "Depositing funds...", - description: "Please review transactions to approve.", - type: "warn", + message: 'Depositing funds...', + description: 'Please review transactions to approve.', + type: 'warn', }); const isInitalized = true; // TODO: finish reserve init @@ -41,9 +28,7 @@ export const deposit = async ( const instructions: TransactionInstruction[] = []; const cleanupInstructions: TransactionInstruction[] = []; - const accountRentExempt = await connection.getMinimumBalanceForRentExemption( - AccountLayout.span - ); + const accountRentExempt = await connection.getMinimumBalanceForRentExemption(AccountLayout.span); const [authority] = await PublicKey.findProgramAddress( [reserve.lendingMarket.toBuffer()], // which account should be authority @@ -61,14 +46,7 @@ export const deposit = async ( // create approval for transfer transactions instructions.push( - Token.createApproveInstruction( - TOKEN_PROGRAM_ID, - fromAccount, - authority, - wallet.publicKey, - [], - amountLamports - ) + Token.createApproveInstruction(TOKEN_PROGRAM_ID, fromAccount, authority, wallet.publicKey, [], amountLamports) ); let toAccount: PublicKey; @@ -84,12 +62,7 @@ export const deposit = async ( signers ); } else { - toAccount = createUninitializedAccount( - instructions, - wallet.publicKey, - accountRentExempt, - signers - ); + toAccount = createUninitializedAccount(instructions, wallet.publicKey, accountRentExempt, signers); } if (isInitalized) { @@ -127,17 +100,11 @@ export const deposit = async ( } try { - let tx = await sendTransaction( - connection, - wallet, - instructions.concat(cleanupInstructions), - signers, - true - ); + let tx = await sendTransaction(connection, wallet, instructions.concat(cleanupInstructions), signers, true); notify({ - message: "Funds deposited.", - type: "success", + message: 'Funds deposited.', + type: 'success', description: `Transaction - ${tx}`, }); } catch { diff --git a/src/actions/liquidate.tsx b/src/actions/liquidate.tsx index 7f4df60..45f3293 100644 --- a/src/actions/liquidate.tsx +++ b/src/actions/liquidate.tsx @@ -1,21 +1,15 @@ -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 { liquidateInstruction } from "./../models/lending/liquidate"; -import { AccountLayout, Token } from "@solana/spl-token"; -import { LENDING_PROGRAM_ID, TOKEN_PROGRAM_ID } from "../constants/ids"; -import { createTempMemoryAccount, ensureSplAccount, findOrCreateAccountByMint } from "./account"; -import { LendingMarket, LendingObligation, TokenAccount } from "../models"; -import { cache, ParsedAccount } from "../contexts/accounts"; +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 { liquidateInstruction } from './../models/lending/liquidate'; +import { AccountLayout, Token } from '@solana/spl-token'; +import { createTempMemoryAccount, ensureSplAccount, findOrCreateAccountByMint } from './account'; +import { LendingMarket, LendingObligation, TokenAccount } from '../models'; +import { cache, ParsedAccount } from '../contexts/accounts'; +import { LENDING_PROGRAM_ID, TOKEN_PROGRAM_ID } from '../utils/ids'; export const liquidate = async ( - connection: Connection, wallet: any, from: TokenAccount, // liquidity account @@ -26,12 +20,12 @@ export const liquidate = async ( repayReserve: ParsedAccount, - withdrawReserve: ParsedAccount, + withdrawReserve: ParsedAccount ) => { notify({ - message: "Repaing funds...", - description: "Please review transactions to approve.", - type: "warn", + message: 'Repaing funds...', + description: 'Please review transactions to approve.', + type: 'warn', }); // user from account @@ -39,9 +33,7 @@ export const liquidate = async ( const instructions: TransactionInstruction[] = []; const cleanupInstructions: TransactionInstruction[] = []; - const accountRentExempt = await connection.getMinimumBalanceForRentExemption( - AccountLayout.span - ); + const accountRentExempt = await connection.getMinimumBalanceForRentExemption(AccountLayout.span); const [authority] = await PublicKey.findProgramAddress( [repayReserve.info.lendingMarket.toBuffer()], @@ -59,14 +51,7 @@ export const liquidate = async ( // create approval for transfer transactions instructions.push( - Token.createApproveInstruction( - TOKEN_PROGRAM_ID, - fromAccount, - authority, - wallet.publicKey, - [], - amountLamports - ) + Token.createApproveInstruction(TOKEN_PROGRAM_ID, fromAccount, authority, wallet.publicKey, [], amountLamports) ); // get destination account @@ -89,24 +74,15 @@ export const liquidate = async ( throw new Error(`Dex market doesn't exist.`); } - const market = cache.get(withdrawReserve.info.lendingMarket) as ParsedAccount< - LendingMarket - >; + const market = cache.get(withdrawReserve.info.lendingMarket) as ParsedAccount; - const dexOrderBookSide = market.info.quoteMint.equals( - repayReserve.info.liquidityMint - ) + const dexOrderBookSide = market.info.quoteMint.equals(repayReserve.info.liquidityMint) ? dexMarket?.info.bids : dexMarket?.info.asks; + console.log(dexMarketAddress.toBase58()); - console.log(dexMarketAddress.toBase58()) - - const memory = createTempMemoryAccount( - instructions, - wallet.publicKey, - signers - ); + const memory = createTempMemoryAccount(instructions, wallet.publicKey, signers); instructions.push( liquidateInstruction( @@ -125,17 +101,11 @@ export const liquidate = async ( ) ); - let tx = await sendTransaction( - connection, - wallet, - instructions.concat(cleanupInstructions), - signers, - true - ); + let tx = await sendTransaction(connection, wallet, instructions.concat(cleanupInstructions), signers, true); notify({ - message: "Funds liquidated.", - type: "success", + message: 'Funds liquidated.', + type: 'success', description: `Transaction - ${tx}`, }); }; diff --git a/src/actions/repay.tsx b/src/actions/repay.tsx index f45c11c..36ed300 100644 --- a/src/actions/repay.tsx +++ b/src/actions/repay.tsx @@ -1,18 +1,13 @@ -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"; -import { ParsedAccount } from "../contexts/accounts"; +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 '../utils/ids'; +import { findOrCreateAccountByMint } from './account'; +import { LendingObligation, TokenAccount } from '../models'; +import { ParsedAccount } from '../contexts/accounts'; export const repay = async ( from: TokenAccount, // CollateralAccount @@ -31,9 +26,9 @@ export const repay = async ( wallet: any ) => { notify({ - message: "Repaing funds...", - description: "Please review transactions to approve.", - type: "warn", + message: 'Repaing funds...', + description: 'Please review transactions to approve.', + type: 'warn', }); // user from account @@ -41,9 +36,7 @@ export const repay = async ( const instructions: TransactionInstruction[] = []; const cleanupInstructions: TransactionInstruction[] = []; - const accountRentExempt = await connection.getMinimumBalanceForRentExemption( - AccountLayout.span - ); + const accountRentExempt = await connection.getMinimumBalanceForRentExemption(AccountLayout.span); const [authority] = await PublicKey.findProgramAddress( [repayReserve.info.lendingMarket.toBuffer()], @@ -54,14 +47,7 @@ export const repay = async ( // create approval for transfer transactions instructions.push( - Token.createApproveInstruction( - TOKEN_PROGRAM_ID, - fromAccount, - authority, - wallet.publicKey, - [], - amountLamports - ) + Token.createApproveInstruction(TOKEN_PROGRAM_ID, fromAccount, authority, wallet.publicKey, [], amountLamports) ); // get destination account @@ -105,17 +91,11 @@ export const repay = async ( ) ); - let tx = await sendTransaction( - connection, - wallet, - instructions.concat(cleanupInstructions), - signers, - true - ); + let tx = await sendTransaction(connection, wallet, instructions.concat(cleanupInstructions), signers, true); notify({ - message: "Funds repaid.", - type: "success", + message: 'Funds repaid.', + type: 'success', description: `Transaction - ${tx}`, }); }; diff --git a/src/actions/withdraw.tsx b/src/actions/withdraw.tsx index 585bf67..ac1e142 100644 --- a/src/actions/withdraw.tsx +++ b/src/actions/withdraw.tsx @@ -1,16 +1,11 @@ -import { - Account, - Connection, - PublicKey, - TransactionInstruction, -} from "@solana/web3.js"; -import { sendTransaction } from "../contexts/connection"; -import { notify } from "../utils/notifications"; -import { LendingReserve, withdrawInstruction } from "./../models/lending"; -import { AccountLayout, Token } from "@solana/spl-token"; -import { LENDING_PROGRAM_ID, TOKEN_PROGRAM_ID } from "../constants/ids"; -import { findOrCreateAccountByMint } from "./account"; -import { TokenAccount } from "../models"; +import { Account, Connection, PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { sendTransaction } from '../contexts/connection'; +import { notify } from '../utils/notifications'; +import { LendingReserve, withdrawInstruction } from './../models/lending'; +import { AccountLayout, Token } from '@solana/spl-token'; +import { LENDING_PROGRAM_ID, TOKEN_PROGRAM_ID } from '../utils/ids'; +import { findOrCreateAccountByMint } from './account'; +import { TokenAccount } from '../models'; export const withdraw = async ( from: TokenAccount, // CollateralAccount @@ -21,9 +16,9 @@ export const withdraw = async ( wallet: any ) => { notify({ - message: "Withdrawing funds...", - description: "Please review transactions to approve.", - type: "warn", + message: 'Withdrawing funds...', + description: 'Please review transactions to approve.', + type: 'warn', }); // user from account @@ -31,27 +26,15 @@ export const withdraw = async ( const instructions: TransactionInstruction[] = []; const cleanupInstructions: TransactionInstruction[] = []; - const accountRentExempt = await connection.getMinimumBalanceForRentExemption( - AccountLayout.span - ); + const accountRentExempt = await connection.getMinimumBalanceForRentExemption(AccountLayout.span); - const [authority] = await PublicKey.findProgramAddress( - [reserve.lendingMarket.toBuffer()], - LENDING_PROGRAM_ID - ); + const [authority] = await PublicKey.findProgramAddress([reserve.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 - ) + Token.createApproveInstruction(TOKEN_PROGRAM_ID, fromAccount, authority, wallet.publicKey, [], amountLamports) ); // get destination account @@ -78,17 +61,11 @@ export const withdraw = async ( ); try { - let tx = await sendTransaction( - connection, - wallet, - instructions.concat(cleanupInstructions), - signers, - true - ); + let tx = await sendTransaction(connection, wallet, instructions.concat(cleanupInstructions), signers, true); notify({ - message: "Funds deposited.", - type: "success", + message: 'Funds deposited.', + type: 'success', description: `Transaction - ${tx}`, }); } catch { diff --git a/src/constants/ids.tsx b/src/constants/ids.tsx deleted file mode 100644 index 531db7c..0000000 --- a/src/constants/ids.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { PublicKey } from "@solana/web3.js"; - -export const WRAPPED_SOL_MINT = new PublicKey( - "So11111111111111111111111111111111111111112" -); -export let TOKEN_PROGRAM_ID = new PublicKey( - "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" -); - -export let LENDING_PROGRAM_ID = new PublicKey( - "TokenLend1ng1111111111111111111111111111111" -); - -export const setProgramIds = (envName: string) => { - // Add dynamic program ids - if (envName === "mainnet-beta") { - LENDING_PROGRAM_ID = new PublicKey( - "2KfJP7pZ6QSpXa26RmsN6kKVQteDEdQmizLSvuyryeiW" - ); - } -}; - -export const programIds = () => { - return { - token: TOKEN_PROGRAM_ID, - lending: LENDING_PROGRAM_ID, - }; -}; diff --git a/src/constants/index.tsx b/src/constants/index.tsx index 19646a6..ea10b86 100644 --- a/src/constants/index.tsx +++ b/src/constants/index.tsx @@ -1,4 +1,3 @@ -export * from "./ids"; -export * from "./labels"; -export * from "./math"; -export * from "./marks"; +export * from './labels'; +export * from './math'; +export * from './marks'; diff --git a/src/constants/labels.ts b/src/constants/labels.ts index 3961490..4c83fa8 100644 --- a/src/constants/labels.ts +++ b/src/constants/labels.ts @@ -63,4 +63,7 @@ export const LABELS = { MARGIN_TRADE_ACTION: 'Margin Trade', MARGIN_TRADE_QUESTION: 'How much of this asset would you like?', TABLE_TITLE_BUYING_POWER: 'Total Buying Power', + NOT_ENOUGH_MARGIN_MESSAGE: 'Not enough buying power in oyster to make this trade at this leverage.', + LEVERAGE_LIMIT_MESSAGE: + 'With liquidity pools in their current state, you are not allowed to use leverage at this multiple. You will need more margin to make this trade.', }; diff --git a/src/contexts/accounts.tsx b/src/contexts/accounts.tsx index 2cdada2..592965a 100644 --- a/src/contexts/accounts.tsx +++ b/src/contexts/accounts.tsx @@ -1,17 +1,21 @@ -import React, { useCallback, useContext, useEffect, useState } from "react"; -import { useConnection } from "./connection"; -import { useWallet } from "./wallet"; -import { AccountInfo, Connection, PublicKey } from "@solana/web3.js"; -import { programIds, WRAPPED_SOL_MINT } from "./../constants/ids"; -import { AccountLayout, u64, MintInfo, MintLayout } from "@solana/spl-token"; -import { TokenAccount } from "./../models"; -import { chunks } from "./../utils/utils"; -import { EventEmitter } from "./../utils/eventEmitter"; +import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { useConnection } from './connection'; +import { useWallet } from './wallet'; +import { AccountInfo, Connection, PublicKey } from '@solana/web3.js'; +import { AccountLayout, u64, MintInfo, MintLayout } from '@solana/spl-token'; +import { PoolInfo, TokenAccount } from './../models'; +import { chunks } from './../utils/utils'; +import { EventEmitter } from './../utils/eventEmitter'; +import { useUserAccounts } from '../hooks/useUserAccounts'; +import { usePools } from '../utils/pools'; +import { WRAPPED_SOL_MINT, programIds } from '../utils/ids'; const AccountsContext = React.createContext(null); const pendingCalls = new Map>(); const genericCache = new Map(); +const pendingMintCalls = new Map>(); +const mintCache = new Map(); export interface ParsedAccountBase { pubkey: PublicKey; @@ -19,15 +23,23 @@ export interface ParsedAccountBase { info: any; // TODO: change to unkown } -export type AccountParser = ( - pubkey: PublicKey, - data: AccountInfo -) => ParsedAccountBase | undefined; +export type AccountParser = (pubkey: PublicKey, data: AccountInfo) => ParsedAccountBase | undefined; export interface ParsedAccount extends ParsedAccountBase { info: T; } +const getMintInfo = async (connection: Connection, pubKey: PublicKey) => { + const info = await connection.getAccountInfo(pubKey); + if (info === null) { + throw new Error('Failed to find mint account'); + } + + const data = Buffer.from(info.data); + + return deserializeMint(data); +}; + export const MintParser = (pubKey: PublicKey, info: AccountInfo) => { const buffer = Buffer.from(info.data); @@ -44,10 +56,7 @@ export const MintParser = (pubKey: PublicKey, info: AccountInfo) => { return details; }; -export const TokenAccountParser = ( - pubKey: PublicKey, - info: AccountInfo -) => { +export const TokenAccountParser = (pubKey: PublicKey, info: AccountInfo) => { const buffer = Buffer.from(info.data); const data = deserializeAccount(buffer); @@ -62,10 +71,7 @@ export const TokenAccountParser = ( return details; }; -export const GenericAccountParser = ( - pubKey: PublicKey, - info: AccountInfo -) => { +export const GenericAccountParser = (pubKey: PublicKey, info: AccountInfo) => { const buffer = Buffer.from(info.data); const details = { @@ -83,13 +89,9 @@ export const keyToAccountParser = new Map(); export const cache = { emitter: new EventEmitter(), - query: async ( - connection: Connection, - pubKey: string | PublicKey, - parser?: AccountParser - ) => { + query: async (connection: Connection, pubKey: string | PublicKey, parser?: AccountParser) => { let id: PublicKey; - if (typeof pubKey === "string") { + if (typeof pubKey === 'string') { id = new PublicKey(pubKey); } else { id = pubKey; @@ -110,7 +112,7 @@ export const cache = { // TODO: refactor to use multiple accounts query with flush like behavior query = connection.getAccountInfo(id).then((data) => { if (!data) { - throw new Error("Account not found"); + throw new Error('Account not found'); } return cache.add(id, data, parser); @@ -119,17 +121,11 @@ export const cache = { return query; }, - add: ( - id: PublicKey | string, - obj: AccountInfo, - parser?: AccountParser - ) => { - const address = typeof id === "string" ? id : id?.toBase58(); + add: (id: PublicKey | string, obj: AccountInfo, parser?: AccountParser) => { + const address = typeof id === 'string' ? id : id?.toBase58(); const deserialize = parser ? parser : keyToAccountParser.get(address); if (!deserialize) { - throw new Error( - "Deserializer needs to be registered or passed as a parameter" - ); + throw new Error('Deserializer needs to be registered or passed as a parameter'); } cache.registerParser(id, deserialize); @@ -147,7 +143,7 @@ export const cache = { }, get: (pubKey: string | PublicKey) => { let key: string; - if (typeof pubKey !== "string") { + if (typeof pubKey !== 'string') { key = pubKey.toBase58(); } else { key = pubKey; @@ -155,6 +151,22 @@ export const cache = { return genericCache.get(key); }, + delete: (pubKey: string | PublicKey) => { + let key: string; + if (typeof pubKey !== 'string') { + key = pubKey.toBase58(); + } else { + key = pubKey; + } + + if (genericCache.get(key)) { + genericCache.delete(key); + cache.emitter.raiseCacheDeleted(key); + return true; + } + return false; + }, + byParser: (parser: AccountParser) => { const result: string[] = []; for (const id of keyToAccountParser.keys()) { @@ -167,12 +179,57 @@ export const cache = { }, registerParser: (pubkey: PublicKey | string, parser: AccountParser) => { if (pubkey) { - const address = typeof pubkey === "string" ? pubkey : pubkey?.toBase58(); + const address = typeof pubkey === 'string' ? pubkey : pubkey?.toBase58(); keyToAccountParser.set(address, parser); } return pubkey; }, + queryMint: async (connection: Connection, pubKey: string | PublicKey) => { + let id: PublicKey; + if (typeof pubKey === 'string') { + id = new PublicKey(pubKey); + } else { + id = pubKey; + } + + const address = id.toBase58(); + let mint = mintCache.get(address); + if (mint) { + return mint; + } + + let query = pendingMintCalls.get(address); + if (query) { + return query; + } + + query = getMintInfo(connection, id).then((data) => { + pendingMintCalls.delete(address); + + mintCache.set(address, data); + return data; + }) as Promise; + pendingMintCalls.set(address, query as any); + + return query; + }, + getMint: (pubKey: string | PublicKey) => { + let key: string; + if (typeof pubKey !== 'string') { + key = pubKey.toBase58(); + } else { + key = pubKey; + } + + return mintCache.get(key); + }, + addMint: (pubKey: PublicKey, obj: AccountInfo) => { + const mint = deserializeMint(obj.data); + const id = pubKey.toBase58(); + mintCache.set(id, mint); + return mint; + }, }; export const useAccountsContext = () => { @@ -181,10 +238,7 @@ export const useAccountsContext = () => { return context; }; -function wrapNativeAccount( - pubkey: PublicKey, - account?: AccountInfo -): TokenAccount | undefined { +function wrapNativeAccount(pubkey: PublicKey, account?: AccountInfo): TokenAccount | undefined { if (!account) { return undefined; } @@ -207,6 +261,27 @@ function wrapNativeAccount( }; } +export function useCachedPool(legacy = false) { + const context = useContext(AccountsContext); + + const allPools = context.pools as PoolInfo[]; + const pools = useMemo(() => { + return allPools.filter((p) => p.legacy === legacy); + }, [allPools, legacy]); + + return { + pools, + }; +} + +export const getCachedAccount = (predicate: (account: TokenAccount) => boolean) => { + for (const account of genericCache.values()) { + if (predicate(account)) { + return account as TokenAccount; + } + } +}; + const UseNativeAccount = () => { const connection = useConnection(); const { wallet } = useWallet(); @@ -249,10 +324,7 @@ const UseNativeAccount = () => { }; const PRECACHED_OWNERS = new Set(); -const precacheUserTokenAccounts = async ( - connection: Connection, - owner?: PublicKey -) => { +const precacheUserTokenAccounts = async (connection: Connection, owner?: PublicKey) => { if (!owner) { return; } @@ -275,28 +347,25 @@ export function AccountsProvider({ children = null as any }) { const [tokenAccounts, setTokenAccounts] = useState([]); const [userAccounts, setUserAccounts] = useState([]); const { nativeAccount } = UseNativeAccount(); + const { pools } = usePools(); const selectUserAccounts = useCallback(() => { return cache .byParser(TokenAccountParser) .map((id) => cache.get(id)) - .filter( - (a) => a && a.info.owner.toBase58() === wallet.publicKey?.toBase58() - ) + .filter((a) => a && a.info.owner.toBase58() === wallet.publicKey?.toBase58()) .map((a) => a as TokenAccount); }, [wallet]); useEffect(() => { - const accounts = selectUserAccounts().filter( - (a) => a !== undefined - ) as TokenAccount[]; + const accounts = selectUserAccounts().filter((a) => a !== undefined) as TokenAccount[]; setUserAccounts(accounts); }, [nativeAccount, wallet, tokenAccounts, selectUserAccounts]); useEffect(() => { const subs: number[] = []; cache.emitter.onCache((args) => { - if(args.isNew) { + if (args.isNew) { let id = args.id; let deserialize = args.parser; connection.onAccountChange(new PublicKey(id), (info) => { @@ -306,8 +375,8 @@ export function AccountsProvider({ children = null as any }) { }); return () => { - subs.forEach(id => connection.removeAccountChangeListener(id)); - } + subs.forEach((id) => connection.removeAccountChangeListener(id)); + }; }, [connection]); const publicKey = wallet?.publicKey; @@ -320,7 +389,7 @@ export function AccountsProvider({ children = null as any }) { }); // This can return different types of accounts: token-account, mint, multisig - // TODO: web3.js expose ability to filter. + // TODO: web3.js expose ability to filter. // this should use only filter syntax to only get accounts that are owned by user const tokenSubID = connection.onProgramAccountChange( programIds().token, @@ -337,7 +406,7 @@ export function AccountsProvider({ children = null as any }) { } } }, - "singleGossip" + 'singleGossip' ); return () => { @@ -350,6 +419,7 @@ export function AccountsProvider({ children = null as any }) { @@ -365,15 +435,9 @@ export function useNativeAccount() { }; } -export const getMultipleAccounts = async ( - connection: any, - keys: string[], - commitment: string -) => { +export const getMultipleAccounts = async (connection: any, keys: string[], commitment: string) => { const result = await Promise.all( - chunks(keys, 99).map((chunk) => - getMultipleAccountsCore(connection, chunk, commitment) - ) + chunks(keys, 99).map((chunk) => getMultipleAccountsCore(connection, chunk, commitment)) ); const array = result @@ -388,7 +452,7 @@ export const getMultipleAccounts = async ( const { data, ...rest } = acc; const obj = { ...rest, - data: Buffer.from(data[0], "base64"), + data: Buffer.from(data[0], 'base64'), } as AccountInfo; return obj; }) @@ -398,18 +462,12 @@ export const getMultipleAccounts = async ( return { keys, array }; }; -const getMultipleAccountsCore = async ( - connection: any, - keys: string[], - commitment: string -) => { - const args = connection._buildArgs([keys], commitment, "base64"); +const getMultipleAccountsCore = async (connection: any, keys: string[], commitment: string) => { + const args = connection._buildArgs([keys], commitment, 'base64'); - const unsafeRes = await connection._rpcRequest("getMultipleAccounts", args); + const unsafeRes = await connection._rpcRequest('getMultipleAccounts', args); if (unsafeRes.error) { - throw new Error( - "failed to get info about account " + unsafeRes.error.message - ); + throw new Error('failed to get info about account ' + unsafeRes.error.message); } if (unsafeRes.result.value) { @@ -425,7 +483,7 @@ export function useMint(key?: string | PublicKey) { const connection = useConnection(); const [mint, setMint] = useState(); - const id = typeof key === "string" ? key : key?.toBase58(); + const id = typeof key === 'string' ? key : key?.toBase58(); useEffect(() => { if (!id) { @@ -440,9 +498,7 @@ export function useMint(key?: string | PublicKey) { const dispose = cache.emitter.onCache((e) => { const event = e; if (event.id === id) { - cache - .query(connection, id, MintParser) - .then((mint) => setMint(mint.info as any)); + cache.query(connection, id, MintParser).then((mint) => setMint(mint.info as any)); } }); return () => { @@ -453,6 +509,17 @@ export function useMint(key?: string | PublicKey) { return mint; } +export const useAccountByMint = (mint: string) => { + const { userAccounts } = useUserAccounts(); + const index = userAccounts.findIndex((acc) => acc.info.mint.toBase58() === mint); + + if (index !== -1) { + return userAccounts[index]; + } + + return; +}; + export function useAccount(pubKey?: PublicKey) { const connection = useConnection(); const [account, setAccount] = useState(); @@ -465,9 +532,7 @@ export function useAccount(pubKey?: PublicKey) { return; } - const acc = await cache - .query(connection, key, TokenAccountParser) - .catch((err) => console.log(err)); + const acc = await cache.query(connection, key, TokenAccountParser).catch((err) => console.log(err)); if (acc) { setAccount(acc); } @@ -530,7 +595,7 @@ const deserializeAccount = (data: Buffer) => { // TODO: expose in spl package const deserializeMint = (data: Buffer) => { if (data.length !== MintLayout.span) { - throw new Error("Not a valid Mint"); + throw new Error('Not a valid Mint'); } const mintInfo = MintLayout.decode(data); diff --git a/src/contexts/connection.tsx b/src/contexts/connection.tsx index 90eaf0a..9ec1616 100644 --- a/src/contexts/connection.tsx +++ b/src/contexts/connection.tsx @@ -1,36 +1,25 @@ -import { KnownToken, useLocalStorageState } from "./../utils/utils"; -import { - Account, - clusterApiUrl, - Connection, - Transaction, - TransactionInstruction, -} from "@solana/web3.js"; -import React, { useContext, useEffect, useMemo, useState } from "react"; -import { setProgramIds } from "./../constants/ids"; -import { notify } from "./../utils/notifications"; -import { ExplorerLink } from "../components/ExplorerLink"; -import LocalTokens from "../config/tokens.json"; +import { KnownToken, useLocalStorageState } from './../utils/utils'; +import { Account, clusterApiUrl, Connection, Transaction, TransactionInstruction } from '@solana/web3.js'; +import React, { useContext, useEffect, useMemo, useState } from 'react'; +import { notify } from './../utils/notifications'; +import { ExplorerLink } from '../components/ExplorerLink'; +import LocalTokens from '../config/tokens.json'; +import { setProgramIds } from '../utils/ids'; -export type ENV = - | "mainnet-beta" - | "testnet" - | "devnet" - | "localnet" - | "lending"; +export type ENV = 'mainnet-beta' | 'testnet' | 'devnet' | 'localnet' | 'lending'; export const ENDPOINTS = [ { - name: "mainnet-beta" as ENV, - endpoint: "https://solana-api.projectserum.com/", + name: 'mainnet-beta' as ENV, + endpoint: 'https://solana-api.projectserum.com/', }, { - name: "lending" as ENV, - endpoint: "https://tln.solana.com", + name: 'lending' as ENV, + endpoint: 'https://tln.solana.com', }, - { name: "testnet" as ENV, endpoint: clusterApiUrl("testnet") }, - { name: "devnet" as ENV, endpoint: clusterApiUrl("devnet") }, - { name: "localnet" as ENV, endpoint: "http://127.0.0.1:8899" }, + { name: 'testnet' as ENV, endpoint: clusterApiUrl('testnet') }, + { name: 'devnet' as ENV, endpoint: clusterApiUrl('devnet') }, + { name: 'localnet' as ENV, endpoint: 'http://127.0.0.1:8899' }, ]; const DEFAULT = ENDPOINTS[0].endpoint; @@ -53,43 +42,29 @@ const ConnectionContext = React.createContext({ setEndpoint: () => {}, slippage: DEFAULT_SLIPPAGE, setSlippage: (val: number) => {}, - connection: new Connection(DEFAULT, "recent"), - sendConnection: new Connection(DEFAULT, "recent"), + connection: new Connection(DEFAULT, 'recent'), + sendConnection: new Connection(DEFAULT, 'recent'), env: ENDPOINTS[0].name, tokens: [], tokenMap: new Map(), }); export function ConnectionProvider({ children = undefined as any }) { - const [endpoint, setEndpoint] = useLocalStorageState( - "connectionEndpts", - ENDPOINTS[0].endpoint - ); + const [endpoint, setEndpoint] = useLocalStorageState('connectionEndpts', ENDPOINTS[0].endpoint); - const [slippage, setSlippage] = useLocalStorageState( - "slippage", - DEFAULT_SLIPPAGE.toString() - ); + const [slippage, setSlippage] = useLocalStorageState('slippage', DEFAULT_SLIPPAGE.toString()); - const connection = useMemo(() => new Connection(endpoint, "recent"), [ - endpoint, - ]); - const sendConnection = useMemo(() => new Connection(endpoint, "recent"), [ - endpoint, - ]); + const connection = useMemo(() => new Connection(endpoint, 'recent'), [endpoint]); + const sendConnection = useMemo(() => new Connection(endpoint, 'recent'), [endpoint]); - const env = - ENDPOINTS.find((end) => end.endpoint === endpoint)?.name || - ENDPOINTS[0].name; + const env = ENDPOINTS.find((end) => end.endpoint === endpoint)?.name || ENDPOINTS[0].name; const [tokens, setTokens] = useState([]); const [tokenMap, setTokenMap] = useState>(new Map()); useEffect(() => { // fetch token files window - .fetch( - `https://raw.githubusercontent.com/solana-labs/token-list/main/src/tokens/${env}.json` - ) + .fetch(`https://raw.githubusercontent.com/solana-labs/token-list/main/src/tokens/${env}.json`) .then((res) => { return res.json(); }) @@ -125,10 +100,7 @@ export function ConnectionProvider({ children = undefined as any }) { }, [connection]); useEffect(() => { - const id = sendConnection.onAccountChange( - new Account().publicKey, - () => {} - ); + const id = sendConnection.onAccountChange(new Account().publicKey, () => {}); return () => { sendConnection.removeAccountChangeListener(id); }; @@ -186,7 +158,7 @@ export function useSlippageConfig() { const getErrorForTransaction = async (connection: Connection, txid: string) => { // wait for all confirmation before geting transaction - await connection.confirmTransaction(txid, "max"); + await connection.confirmTransaction(txid, 'max'); const tx = await connection.getParsedConfirmedTransaction(txid); @@ -220,9 +192,7 @@ export const sendTransaction = async ( ) => { let transaction = new Transaction(); instructions.forEach((instruction) => transaction.add(instruction)); - transaction.recentBlockhash = ( - await connection.getRecentBlockhash("max") - ).blockhash; + transaction.recentBlockhash = (await connection.getRecentBlockhash('max')).blockhash; transaction.setSigners( // fee payied by the wallet owner wallet.publicKey, @@ -235,37 +205,30 @@ export const sendTransaction = async ( const rawTransaction = transaction.serialize(); let options = { skipPreflight: true, - commitment: "singleGossip", + commitment: 'singleGossip', }; const txid = await connection.sendRawTransaction(rawTransaction, options); if (awaitConfirmation) { - const status = ( - await connection.confirmTransaction( - txid, - options && (options.commitment as any) - ) - ).value; + const status = (await connection.confirmTransaction(txid, options && (options.commitment as any))).value; if (status?.err) { const errors = await getErrorForTransaction(connection, txid); notify({ - message: "Transaction failed...", + message: 'Transaction failed...', description: ( <> {errors.map((err) => (
{err}
))} - + ), - type: "error", + type: 'error', }); - throw new Error( - `Raw transaction ${txid} failed (${JSON.stringify(status)})` - ); + throw new Error(`Raw transaction ${txid} failed (${JSON.stringify(status)})`); } } diff --git a/src/contexts/lending.tsx b/src/contexts/lending.tsx index a1939eb..0a3e02d 100644 --- a/src/contexts/lending.tsx +++ b/src/contexts/lending.tsx @@ -1,6 +1,6 @@ -import React, { useCallback, useEffect, useState } from "react"; -import { useConnection } from "./connection"; -import { LENDING_PROGRAM_ID } from "./../constants/ids"; +import React, { useCallback, useEffect, useState } from 'react'; +import { useConnection } from './connection'; +import { LENDING_PROGRAM_ID } from './../utils/ids'; import { LendingMarketParser, isLendingReserve, @@ -9,17 +9,12 @@ import { LendingReserve, isLendingObligation, LendingObligationParser, -} from "./../models/lending"; -import { - cache, - getMultipleAccounts, - MintParser, - ParsedAccount, -} from "./accounts"; -import { PublicKey } from "@solana/web3.js"; -import { DexMarketParser } from "../models/dex"; -import { usePrecacheMarket } from "./market"; -import { useLendingReserves } from "../hooks"; +} from './../models/lending'; +import { cache, getMultipleAccounts, MintParser, ParsedAccount } from './accounts'; +import { PublicKey } from '@solana/web3.js'; +import { DexMarketParser } from '../models/dex'; +import { usePrecacheMarket } from './market'; +import { useLendingReserves } from '../hooks'; export interface LendingContextState {} @@ -48,33 +43,19 @@ export const useLending = () => { const processAccount = useCallback((item) => { if (isLendingReserve(item.account)) { - const reserve = cache.add( - item.pubkey.toBase58(), - item.account, - LendingReserveParser - ); + const reserve = cache.add(item.pubkey.toBase58(), item.account, LendingReserveParser); return reserve; } else if (isLendingMarket(item.account)) { - return cache.add( - item.pubkey.toBase58(), - item.account, - LendingMarketParser - ); + return cache.add(item.pubkey.toBase58(), item.account, LendingMarketParser); } else if (isLendingObligation(item.account)) { - return cache.add( - item.pubkey.toBase58(), - item.account, - LendingObligationParser - ); + return cache.add(item.pubkey.toBase58(), item.account, LendingObligationParser); } }, []); useEffect(() => { if (reserveAccounts.length > 0) { - precacheMarkets( - reserveAccounts.map((reserve) => reserve.info.liquidityMint.toBase58()) - ); + precacheMarkets(reserveAccounts.map((reserve) => reserve.info.liquidityMint.toBase58())); } }, [reserveAccounts, precacheMarkets]); @@ -83,36 +64,21 @@ export const useLending = () => { setLendingAccounts([]); const queryLendingAccounts = async () => { - const programAccounts = await connection.getProgramAccounts( - LENDING_PROGRAM_ID - ); + const programAccounts = await connection.getProgramAccounts(LENDING_PROGRAM_ID); - const accounts = programAccounts - .map(processAccount) - .filter((item) => item !== undefined); + const accounts = programAccounts.map(processAccount).filter((item) => item !== undefined); const lendingReserves = accounts - .filter( - (acc) => (acc?.info as LendingReserve).lendingMarket !== undefined - ) + .filter((acc) => (acc?.info as LendingReserve).lendingMarket !== undefined) .map((acc) => acc as ParsedAccount); const toQuery = [ ...lendingReserves.map((acc) => { const result = [ - cache.registerParser( - acc?.info.collateralMint.toBase58(), - MintParser - ), - cache.registerParser( - acc?.info.liquidityMint.toBase58(), - MintParser - ), + cache.registerParser(acc?.info.collateralMint.toBase58(), MintParser), + cache.registerParser(acc?.info.liquidityMint.toBase58(), MintParser), // ignore dex if its not set - cache.registerParser( - acc?.info.dexMarketOption ? acc?.info.dexMarket.toBase58() : "", - DexMarketParser - ), + cache.registerParser(acc?.info.dexMarketOption ? acc?.info.dexMarket.toBase58() : '', DexMarketParser), ].filter((_) => _); return result; }), @@ -120,15 +86,13 @@ export const useLending = () => { // This will pre-cache all accounts used by pools // All those accounts are updated whenever there is a change - await getMultipleAccounts(connection, toQuery, "single").then( - ({ keys, array }) => { - return array.map((obj, index) => { - const address = keys[index]; - cache.add(address, obj); - return obj; - }) as any[]; - } - ); + await getMultipleAccounts(connection, toQuery, 'single').then(({ keys, array }) => { + return array.map((obj, index) => { + const address = keys[index]; + cache.add(address, obj); + return obj; + }) as any[]; + }); // HACK: fix, force account refresh programAccounts.map(processAccount).filter((item) => item !== undefined); @@ -152,7 +116,7 @@ export const useLending = () => { }; processAccount(item); }, - "singleGossip" + 'singleGossip' ); return () => { diff --git a/src/contexts/market.tsx b/src/contexts/market.tsx index a3301d2..aefcb2a 100644 --- a/src/contexts/market.tsx +++ b/src/contexts/market.tsx @@ -1,15 +1,25 @@ -import React, { useCallback, useContext, useEffect, useState } from "react"; -import { MINT_TO_MARKET } from "./../models/marketOverrides"; -import { fromLamports, STABLE_COINS } from "./../utils/utils"; -import { useConnectionConfig } from "./connection"; -import { cache, getMultipleAccounts, ParsedAccount } from "./accounts"; -import { Market, MARKETS, Orderbook, TOKEN_MINTS } from "@project-serum/serum"; -import { AccountInfo, Connection, PublicKey } from "@solana/web3.js"; -import { useMemo } from "react"; -import { EventEmitter } from "./../utils/eventEmitter"; +import React, { useCallback, useContext, useEffect, useState } from 'react'; +import { MINT_TO_MARKET } from './../models/marketOverrides'; +import { POOLS_WITH_AIRDROP } from './../models/airdrops'; +import { convert, fromLamports, getPoolName, getTokenName, KnownTokenMap, STABLE_COINS } from './../utils/utils'; +import { useConnection, useConnectionConfig } from './connection'; +import { cache, getMultipleAccounts, ParsedAccount } from './accounts'; +import { Market, MARKETS, Orderbook, TOKEN_MINTS } from '@project-serum/serum'; +import { AccountInfo, Connection, PublicKey } from '@solana/web3.js'; +import { useMemo } from 'react'; +import { EventEmitter } from './../utils/eventEmitter'; -import { DexMarketParser } from "./../models/dex"; -import { LendingMarket, LendingReserve } from "../models"; +import { DexMarketParser } from './../models/dex'; +import { LendingMarket, LendingReserve, PoolInfo } from '../models'; +import { LIQUIDITY_PROVIDER_FEE, SERUM_FEE } from '../utils/pools'; + +const INITAL_LIQUIDITY_DATE = new Date('2020-10-27'); +export const BONFIDA_POOL_INTERVAL = 30 * 60_000; // 30 min + +interface RecentPoolData { + pool_identifier: string; + volume24hA: number; +} export interface MarketsContextState { midPriceInUSD: (mint: string) => number; @@ -20,6 +30,7 @@ export interface MarketsContextState { subscribeToMarket: (mint: string) => () => void; precacheMarkets: (mints: string[]) => void; + dailyVolume: Map; } const REFRESH_INTERVAL = 30_000; @@ -32,24 +43,19 @@ export function MarketProvider({ children = null as any }) { const { endpoint } = useConnectionConfig(); const accountsToObserve = useMemo(() => new Map(), []); const [marketMints, setMarketMints] = useState([]); + const [dailyVolume, setDailyVolume] = useState>(new Map()); - const connection = useMemo(() => new Connection(endpoint, "recent"), [ - endpoint, - ]); + const connection = useMemo(() => new Connection(endpoint, 'recent'), [endpoint]); const marketByMint = useMemo(() => { return [...new Set(marketMints).values()].reduce((acc, key) => { const mintAddress = key; - const SERUM_TOKEN = TOKEN_MINTS.find( - (a) => a.address.toBase58() === mintAddress - ); + const SERUM_TOKEN = TOKEN_MINTS.find((a) => a.address.toBase58() === mintAddress); const marketAddress = MINT_TO_MARKET[mintAddress]; const marketName = `${SERUM_TOKEN?.name}/USDC`; - const marketInfo = MARKETS.find( - (m) => m.name === marketName || m.address.toBase58() === marketAddress - ); + const marketInfo = MARKETS.find((m) => m.name === marketName || m.address.toBase58() === marketAddress); if (marketInfo) { acc.set(mintAddress, { @@ -63,6 +69,7 @@ export function MarketProvider({ children = null as any }) { useEffect(() => { let timer = 0; + let bonfidaTimer = 0; const updateData = async () => { await refreshAccounts(connection, [...accountsToObserve.keys()]); @@ -71,6 +78,23 @@ export function MarketProvider({ children = null as any }) { timer = window.setTimeout(() => updateData(), REFRESH_INTERVAL); }; + const bonfidaQuery = async () => { + try { + const resp = await window.fetch('https://serum-api.bonfida.com/pools-recent'); + const data = await resp.json(); + const map = (data?.data as RecentPoolData[]).reduce((acc, item) => { + acc.set(item.pool_identifier, item); + return acc; + }, new Map()); + + setDailyVolume(map); + } catch { + // ignore + } + + bonfidaTimer = window.setTimeout(() => bonfidaQuery(), BONFIDA_POOL_INTERVAL); + }; + const initalQuery = async () => { const reverseSerumMarketCache = new Map(); [...marketByMint.keys()].forEach((mint) => { @@ -88,7 +112,7 @@ export function MarketProvider({ children = null as any }) { connection, // only query for markets that are not in cahce allMarkets.filter((a) => cache.get(a) === undefined), - "single" + 'single' ).then(({ keys, array }) => { allMarkets.forEach(() => {}); @@ -141,15 +165,13 @@ export function MarketProvider({ children = null as any }) { return () => { window.clearTimeout(timer); + window.clearTimeout(bonfidaTimer); }; }, [marketByMint, accountsToObserve, connection]); const midPriceInUSD = useCallback( (mintAddress: string) => { - return getMidPrice( - marketByMint.get(mintAddress)?.marketInfo.address.toBase58(), - mintAddress - ); + return getMidPrice(marketByMint.get(mintAddress)?.marketInfo.address.toBase58(), mintAddress); }, [marketByMint] ); @@ -157,7 +179,7 @@ export function MarketProvider({ children = null as any }) { const subscribeToMarket = useCallback( (mintAddress: string) => { const info = marketByMint.get(mintAddress); - const market = cache.get(info?.marketInfo.address.toBase58() || ""); + const market = cache.get(info?.marketInfo.address.toBase58() || ''); if (!market) { return () => {}; } @@ -206,6 +228,7 @@ export function MarketProvider({ children = null as any }) { marketByMint, subscribeToMarket, precacheMarkets, + dailyVolume, }} > {children} @@ -218,10 +241,188 @@ export const useMarkets = () => { return context as MarketsContextState; }; +export const useEnrichedPools = (pools: PoolInfo[]) => { + const context = useContext(MarketsContext); + const { tokenMap } = useConnectionConfig(); + const [enriched, setEnriched] = useState([]); + const subscribeToMarket = context?.subscribeToMarket; + const marketEmitter = context?.marketEmitter; + const marketsByMint = context?.marketByMint; + const dailyVolume = context?.dailyVolume; + const poolKeys = pools.map((p) => p.pubkeys.account.toBase58()).join(','); + + useEffect(() => { + if (!marketEmitter || !subscribeToMarket || pools.length == 0) { + return; + } + //@ts-ignore + const mints = [...new Set([...marketsByMint?.keys()]).keys()]; + + const subscriptions = mints.map((m) => subscribeToMarket(m)); + + const update = () => { + setEnriched(createEnrichedPools(pools, marketsByMint, dailyVolume, tokenMap)); + }; + + const dispose = marketEmitter.onMarket(update); + + update(); + + return () => { + dispose && dispose(); + subscriptions.forEach((dispose) => dispose && dispose()); + }; + }, [tokenMap, dailyVolume, poolKeys, subscribeToMarket, marketEmitter, marketsByMint]); + + return enriched; +}; + +// TODO: +// 1. useEnrichedPools +// combines market and pools and user info +// 2. ADD useMidPrice with event to refresh price +// that could subscribe to multiple markets and trigger refresh of those markets only when there is active subscription + +function createEnrichedPools( + pools: PoolInfo[], + marketByMint: Map | undefined, + poolData: Map | undefined, + tokenMap: KnownTokenMap +) { + const TODAY = new Date(); + + if (!marketByMint) { + return []; + } + const result = pools + .filter((p) => p.pubkeys.holdingMints && p.pubkeys.holdingMints.length > 1) + .map((p, index) => { + const mints = (p.pubkeys.holdingMints || []).map((a) => a.toBase58()).sort(); + const mintA = cache.getMint(mints[0]); + const mintB = cache.getMint(mints[1]); + + const account0 = cache.get(p.pubkeys.holdingAccounts[0]); + const account1 = cache.get(p.pubkeys.holdingAccounts[1]); + + const accountA = account0?.info.mint.toBase58() === mints[0] ? account0 : account1; + const accountB = account1?.info.mint.toBase58() === mints[1] ? account1 : account0; + + const baseMid = getMidPrice(marketByMint.get(mints[0])?.marketInfo.address.toBase58() || '', mints[0]); + const baseReserveUSD = baseMid * convert(accountA, mintA); + + const quote = getMidPrice(marketByMint.get(mints[1])?.marketInfo.address.toBase58() || '', mints[1]); + const quoteReserveUSD = quote * convert(accountB, mintB); + + const poolMint = cache.getMint(p.pubkeys.mint); + if (poolMint?.supply.eqn(0)) { + return undefined; + } + + let airdropYield = calculateAirdropYield(p, marketByMint, baseReserveUSD, quoteReserveUSD); + + let volume = 0; + let volume24h = baseMid * (poolData?.get(p.pubkeys.mint.toBase58())?.volume24hA || 0); + let fees24h = volume24h * (LIQUIDITY_PROVIDER_FEE - SERUM_FEE); + let fees = 0; + let apy = airdropYield; + let apy24h = airdropYield; + if (p.pubkeys.feeAccount) { + const feeAccount = cache.get(p.pubkeys.feeAccount); + + if (poolMint && feeAccount && feeAccount.info.mint.toBase58() === p.pubkeys.mint.toBase58()) { + const feeBalance = feeAccount?.info.amount.toNumber(); + const supply = poolMint?.supply.toNumber(); + + const ownedPct = feeBalance / supply; + + const poolOwnerFees = ownedPct * baseReserveUSD + ownedPct * quoteReserveUSD; + volume = poolOwnerFees / 0.0004; + fees = volume * LIQUIDITY_PROVIDER_FEE; + + if (fees !== 0) { + const baseVolume = (ownedPct * baseReserveUSD) / 0.0004; + const quoteVolume = (ownedPct * quoteReserveUSD) / 0.0004; + + // Aproximation not true for all pools we need to fine a better way + const daysSinceInception = Math.floor( + (TODAY.getTime() - INITAL_LIQUIDITY_DATE.getTime()) / (24 * 3600 * 1000) + ); + const apy0 = + parseFloat(((baseVolume / daysSinceInception) * LIQUIDITY_PROVIDER_FEE * 356) as any) / baseReserveUSD; + const apy1 = + parseFloat(((quoteVolume / daysSinceInception) * LIQUIDITY_PROVIDER_FEE * 356) as any) / quoteReserveUSD; + + apy = apy + Math.max(apy0, apy1); + + const apy24h0 = parseFloat((volume24h * LIQUIDITY_PROVIDER_FEE * 356) as any) / baseReserveUSD; + apy24h = apy24h + apy24h0; + } + } + } + + const lpMint = cache.getMint(p.pubkeys.mint); + + const name = getPoolName(tokenMap, p); + const link = `#/?pair=${getPoolName(tokenMap, p, false).replace('/', '-')}`; + + return { + key: p.pubkeys.account.toBase58(), + id: index, + name, + names: mints.map((m) => getTokenName(tokenMap, m)), + accounts: [accountA?.pubkey, accountB?.pubkey], + address: p.pubkeys.mint.toBase58(), + link, + mints, + liquidityA: convert(accountA, mintA), + liquidityAinUsd: baseReserveUSD, + liquidityB: convert(accountB, mintB), + liquidityBinUsd: quoteReserveUSD, + supply: lpMint && (lpMint?.supply.toNumber() / Math.pow(10, lpMint?.decimals || 0)).toFixed(9), + fees, + fees24h, + liquidity: baseReserveUSD + quoteReserveUSD, + volume, + volume24h, + apy: Number.isFinite(apy) ? apy : 0, + apy24h: Number.isFinite(apy24h) ? apy24h : 0, + map: poolData, + extra: poolData?.get(p.pubkeys.account.toBase58()), + raw: p, + }; + }) + .filter((p) => p !== undefined); + return result; +} + +function calculateAirdropYield( + p: PoolInfo, + marketByMint: Map, + baseReserveUSD: number, + quoteReserveUSD: number +) { + let airdropYield = 0; + let poolWithAirdrop = POOLS_WITH_AIRDROP.find((drop) => drop.pool.equals(p.pubkeys.mint)); + if (poolWithAirdrop) { + airdropYield = poolWithAirdrop.airdrops.reduce((acc, item) => { + const market = marketByMint.get(item.mint.toBase58())?.marketInfo.address; + if (market) { + const midPrice = getMidPrice(market?.toBase58(), item.mint.toBase58()); + + acc = + acc + + // airdrop yield + ((item.amount * midPrice) / (baseReserveUSD + quoteReserveUSD)) * (365 / 30); + } + + return acc; + }, 0); + } + return airdropYield; +} + export const useMidPriceInUSD = (mint: string) => { - const { midPriceInUSD, subscribeToMarket, marketEmitter } = useContext( - MarketsContext - ) as MarketsContextState; + const { midPriceInUSD, subscribeToMarket, marketEmitter } = useContext(MarketsContext) as MarketsContextState; const [price, setPrice] = useState(0); useEffect(() => { @@ -249,11 +450,7 @@ export const usePrecacheMarket = () => { return context.precacheMarkets; }; -export const simulateMarketOrderFill = ( - amount: number, - reserve: LendingReserve, - dex: PublicKey -) => { +export const simulateMarketOrderFill = (amount: number, reserve: LendingReserve, dex: PublicKey) => { const liquidityMint = cache.get(reserve.liquidityMint); const collateralMint = cache.get(reserve.collateralMint); if (!liquidityMint || !collateralMint) { @@ -266,22 +463,12 @@ export const simulateMarketOrderFill = ( } const decodedMarket = marketInfo.info; - const baseMintDecimals = - cache.get(decodedMarket.baseMint)?.info.decimals || 0; - const quoteMintDecimals = - cache.get(decodedMarket.quoteMint)?.info.decimals || 0; + const baseMintDecimals = cache.get(decodedMarket.baseMint)?.info.decimals || 0; + const quoteMintDecimals = cache.get(decodedMarket.quoteMint)?.info.decimals || 0; - const lendingMarket = cache.get(reserve.lendingMarket) as ParsedAccount< - LendingMarket - >; + const lendingMarket = cache.get(reserve.lendingMarket) as ParsedAccount; - const dexMarket = new Market( - decodedMarket, - baseMintDecimals, - quoteMintDecimals, - undefined, - decodedMarket.programId - ); + const dexMarket = new Market(decodedMarket, baseMintDecimals, quoteMintDecimals, undefined, decodedMarket.programId); const bookAccount = lendingMarket.info.quoteMint.equals(reserve.liquidityMint) ? decodedMarket?.bids @@ -320,11 +507,9 @@ export const simulateMarketOrderFill = ( }; const getMidPrice = (marketAddress?: string, mintAddress?: string) => { - const SERUM_TOKEN = TOKEN_MINTS.find( - (a) => a.address.toBase58() === mintAddress - ); + const SERUM_TOKEN = TOKEN_MINTS.find((a) => a.address.toBase58() === mintAddress); - if (STABLE_COINS.has(SERUM_TOKEN?.name || "")) { + if (STABLE_COINS.has(SERUM_TOKEN?.name || '')) { return 1.0; } @@ -339,18 +524,10 @@ const getMidPrice = (marketAddress?: string, mintAddress?: string) => { const decodedMarket = marketInfo.info; - const baseMintDecimals = - cache.get(decodedMarket.baseMint)?.info.decimals || 0; - const quoteMintDecimals = - cache.get(decodedMarket.quoteMint)?.info.decimals || 0; + const baseMintDecimals = cache.get(decodedMarket.baseMint)?.info.decimals || 0; + const quoteMintDecimals = cache.get(decodedMarket.quoteMint)?.info.decimals || 0; - const market = new Market( - decodedMarket, - baseMintDecimals, - quoteMintDecimals, - undefined, - decodedMarket.programId - ); + const market = new Market(decodedMarket, baseMintDecimals, quoteMintDecimals, undefined, decodedMarket.programId); const bids = cache.get(decodedMarket.bids)?.info; const asks = cache.get(decodedMarket.asks)?.info; @@ -375,14 +552,12 @@ const refreshAccounts = async (connection: Connection, keys: string[]) => { return []; } - return getMultipleAccounts(connection, keys, "single").then( - ({ keys, array }) => { - return array.map((item, index) => { - const address = keys[index]; - return cache.add(new PublicKey(address), item); - }); - } - ); + return getMultipleAccounts(connection, keys, 'single').then(({ keys, array }) => { + return array.map((item, index) => { + const address = keys[index]; + return cache.add(new PublicKey(address), item); + }); + }); }; interface SerumMarket { diff --git a/src/models/airdrops.ts b/src/models/airdrops.ts new file mode 100644 index 0000000..2b30181 --- /dev/null +++ b/src/models/airdrops.ts @@ -0,0 +1,11 @@ +import { PublicKey } from '@solana/web3.js'; + +interface PoolAirdrop { + pool: PublicKey; + airdrops: { + mint: PublicKey; + amount: number; + }[]; +} + +export const POOLS_WITH_AIRDROP: PoolAirdrop[] = []; diff --git a/src/models/index.ts b/src/models/index.ts index 76b3452..64e3683 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1,3 +1,5 @@ -export * from "./account"; -export * from "./lending"; -export * from "./totals"; +export * from './account'; +export * from './lending'; +export * from './tokenSwap'; +export * from './pool'; +export * from './totals'; diff --git a/src/models/lending/borrow.ts b/src/models/lending/borrow.ts index 8e51f33..0cf2eb7 100644 --- a/src/models/lending/borrow.ts +++ b/src/models/lending/borrow.ts @@ -1,16 +1,11 @@ -import { - PublicKey, - SYSVAR_CLOCK_PUBKEY, - SYSVAR_RENT_PUBKEY, - TransactionInstruction, -} from "@solana/web3.js"; -import BN from "bn.js"; -import * as BufferLayout from "buffer-layout"; -import { LENDING_PROGRAM_ID, TOKEN_PROGRAM_ID } from "../../constants/ids"; -import { wadToLamports } from "../../utils/utils"; -import * as Layout from "./../../utils/layout"; -import { LendingInstruction } from "./lending"; -import { LendingReserve } from "./reserve"; +import { PublicKey, SYSVAR_CLOCK_PUBKEY, SYSVAR_RENT_PUBKEY, TransactionInstruction } from '@solana/web3.js'; +import BN from 'bn.js'; +import * as BufferLayout from 'buffer-layout'; +import { TOKEN_PROGRAM_ID, LENDING_PROGRAM_ID } from '../../utils/ids'; +import { wadToLamports } from '../../utils/utils'; +import * as Layout from './../../utils/layout'; +import { LendingInstruction } from './lending'; +import { LendingReserve } from './reserve'; export enum BorrowAmountType { LiquidityBorrowAmount = 0, @@ -60,9 +55,9 @@ export const borrowInstruction = ( memory: PublicKey ): TransactionInstruction => { const dataLayout = BufferLayout.struct([ - BufferLayout.u8("instruction"), - Layout.uint64("amount"), - BufferLayout.u8("amountType"), + BufferLayout.u8('instruction'), + Layout.uint64('amount'), + BufferLayout.u8('amountType'), ]); const data = Buffer.alloc(dataLayout.span); @@ -113,8 +108,7 @@ export const borrowInstruction = ( export const calculateBorrowAPY = (reserve: LendingReserve) => { const totalBorrows = wadToLamports(reserve.borrowedLiquidityWad).toNumber(); - const currentUtilization = - totalBorrows / (reserve.availableLiquidity.toNumber() + totalBorrows); + const currentUtilization = totalBorrows / (reserve.availableLiquidity.toNumber() + totalBorrows); const optimalUtilization = reserve.config.optimalUtilizationRate / 100; let borrowAPY; @@ -122,16 +116,12 @@ export const calculateBorrowAPY = (reserve: LendingReserve) => { const normalizedFactor = currentUtilization / optimalUtilization; const optimalBorrowRate = reserve.config.optimalBorrowRate / 100; const minBorrowRate = reserve.config.minBorrowRate / 100; - borrowAPY = - normalizedFactor * (optimalBorrowRate - minBorrowRate) + minBorrowRate; + borrowAPY = normalizedFactor * (optimalBorrowRate - minBorrowRate) + minBorrowRate; } else { - const normalizedFactor = - (currentUtilization - optimalUtilization) / (1 - optimalUtilization); + const normalizedFactor = (currentUtilization - optimalUtilization) / (1 - optimalUtilization); const optimalBorrowRate = reserve.config.optimalBorrowRate / 100; const maxBorrowRate = reserve.config.maxBorrowRate / 100; - borrowAPY = - normalizedFactor * (maxBorrowRate - optimalBorrowRate) + - optimalBorrowRate; + borrowAPY = normalizedFactor * (maxBorrowRate - optimalBorrowRate) + optimalBorrowRate; } return borrowAPY; diff --git a/src/models/lending/deposit.ts b/src/models/lending/deposit.ts index 1df7ce8..b878b80 100644 --- a/src/models/lending/deposit.ts +++ b/src/models/lending/deposit.ts @@ -1,16 +1,12 @@ -import { - PublicKey, - SYSVAR_CLOCK_PUBKEY, - TransactionInstruction, -} from "@solana/web3.js"; -import BN from "bn.js"; -import * as BufferLayout from "buffer-layout"; -import { LENDING_PROGRAM_ID, TOKEN_PROGRAM_ID } from "../../constants/ids"; -import { wadToLamports } from "../../utils/utils"; -import * as Layout from "./../../utils/layout"; -import { calculateBorrowAPY } from "./borrow"; -import { LendingInstruction } from "./lending"; -import { LendingReserve } from "./reserve"; +import { PublicKey, SYSVAR_CLOCK_PUBKEY, TransactionInstruction } from '@solana/web3.js'; +import BN from 'bn.js'; +import * as BufferLayout from 'buffer-layout'; +import { TOKEN_PROGRAM_ID, LENDING_PROGRAM_ID } from '../../utils/ids'; +import { wadToLamports } from '../../utils/utils'; +import * as Layout from './../../utils/layout'; +import { calculateBorrowAPY } from './borrow'; +import { LendingInstruction } from './lending'; +import { LendingReserve } from './reserve'; /// Deposit liquidity into a reserve. The output is a collateral token representing ownership /// of the reserve liquidity pool. @@ -32,10 +28,7 @@ export const depositInstruction = ( reserveSupply: PublicKey, collateralMint: PublicKey ): TransactionInstruction => { - const dataLayout = BufferLayout.struct([ - BufferLayout.u8("instruction"), - Layout.uint64("liquidityAmount"), - ]); + const dataLayout = BufferLayout.struct([BufferLayout.u8('instruction'), Layout.uint64('liquidityAmount')]); const data = Buffer.alloc(dataLayout.span); dataLayout.encode( @@ -65,8 +58,7 @@ export const depositInstruction = ( export const calculateDepositAPY = (reserve: LendingReserve) => { const totalBorrows = wadToLamports(reserve.borrowedLiquidityWad).toNumber(); - const currentUtilization = - totalBorrows / (reserve.availableLiquidity.toNumber() + totalBorrows); + const currentUtilization = totalBorrows / (reserve.availableLiquidity.toNumber() + totalBorrows); const borrowAPY = calculateBorrowAPY(reserve); return currentUtilization * borrowAPY; diff --git a/src/models/lending/liquidate.ts b/src/models/lending/liquidate.ts index 915e1ed..c3c2717 100644 --- a/src/models/lending/liquidate.ts +++ b/src/models/lending/liquidate.ts @@ -1,13 +1,9 @@ -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"; -import * as BufferLayout from "buffer-layout"; -import * as Layout from "./../../utils/layout"; +import { PublicKey, SYSVAR_CLOCK_PUBKEY, TransactionInstruction } from '@solana/web3.js'; +import BN from 'bn.js'; +import { LendingInstruction } from './lending'; +import * as BufferLayout from 'buffer-layout'; +import * as Layout from './../../utils/layout'; +import { TOKEN_PROGRAM_ID, LENDING_PROGRAM_ID } from '../../utils/ids'; /// Purchase collateral tokens at a discount rate if the chosen obligation is unhealthy. /// @@ -38,10 +34,7 @@ export const liquidateInstruction = ( dexOrderBookSide: PublicKey, memory: PublicKey ): TransactionInstruction => { - const dataLayout = BufferLayout.struct([ - BufferLayout.u8("instruction"), - Layout.uint64("liquidityAmount"), - ]); + const dataLayout = BufferLayout.struct([BufferLayout.u8('instruction'), Layout.uint64('liquidityAmount')]); const data = Buffer.alloc(dataLayout.span); dataLayout.encode( diff --git a/src/models/lending/repay.ts b/src/models/lending/repay.ts index aaa0d4f..f6e0acc 100644 --- a/src/models/lending/repay.ts +++ b/src/models/lending/repay.ts @@ -1,13 +1,9 @@ -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"; -import * as BufferLayout from "buffer-layout"; -import * as Layout from "./../../utils/layout"; +import { PublicKey, SYSVAR_CLOCK_PUBKEY, TransactionInstruction } from '@solana/web3.js'; +import BN from 'bn.js'; +import { LendingInstruction } from './lending'; +import * as BufferLayout from 'buffer-layout'; +import * as Layout from './../../utils/layout'; +import { TOKEN_PROGRAM_ID, LENDING_PROGRAM_ID } from '../../utils/ids'; /// Repay loaned tokens to a reserve and receive collateral tokens. The obligation balance /// will be recalculated for interest. @@ -37,10 +33,7 @@ export const repayInstruction = ( obligationInput: PublicKey, authority: PublicKey ): TransactionInstruction => { - const dataLayout = BufferLayout.struct([ - BufferLayout.u8("instruction"), - Layout.uint64("liquidityAmount"), - ]); + const dataLayout = BufferLayout.struct([BufferLayout.u8('instruction'), Layout.uint64('liquidityAmount')]); const data = Buffer.alloc(dataLayout.span); dataLayout.encode( diff --git a/src/models/lending/reserve.ts b/src/models/lending/reserve.ts index 0358e8e..9e1c83f 100644 --- a/src/models/lending/reserve.ts +++ b/src/models/lending/reserve.ts @@ -4,54 +4,52 @@ import { SYSVAR_CLOCK_PUBKEY, SYSVAR_RENT_PUBKEY, TransactionInstruction, -} from "@solana/web3.js"; -import BN from "bn.js"; -import * as BufferLayout from "buffer-layout"; -import { LENDING_PROGRAM_ID, TOKEN_PROGRAM_ID } from "../../constants/ids"; -import { wadToLamports } from "../../utils/utils"; -import * as Layout from "./../../utils/layout"; -import { LendingInstruction } from "./lending"; +} from '@solana/web3.js'; +import BN from 'bn.js'; +import * as BufferLayout from 'buffer-layout'; +import { TOKEN_PROGRAM_ID, LENDING_PROGRAM_ID } from '../../utils/ids'; +import { wadToLamports } from '../../utils/utils'; +import * as Layout from './../../utils/layout'; +import { LendingInstruction } from './lending'; -export const LendingReserveLayout: typeof BufferLayout.Structure = BufferLayout.struct( - [ - Layout.uint64("lastUpdateSlot"), - Layout.publicKey("lendingMarket"), - Layout.publicKey("liquidityMint"), - BufferLayout.u8("liquidityMintDecimals"), - Layout.publicKey("liquiditySupply"), - Layout.publicKey("collateralMint"), - Layout.publicKey("collateralSupply"), - // TODO: replace u32 option with generic quivalent - BufferLayout.u32("dexMarketOption"), - Layout.publicKey("dexMarket"), +export const LendingReserveLayout: typeof BufferLayout.Structure = BufferLayout.struct([ + Layout.uint64('lastUpdateSlot'), + Layout.publicKey('lendingMarket'), + Layout.publicKey('liquidityMint'), + BufferLayout.u8('liquidityMintDecimals'), + Layout.publicKey('liquiditySupply'), + Layout.publicKey('collateralMint'), + Layout.publicKey('collateralSupply'), + // TODO: replace u32 option with generic quivalent + BufferLayout.u32('dexMarketOption'), + Layout.publicKey('dexMarket'), - BufferLayout.struct( - [ - /// Optimal utilization rate as a percent - BufferLayout.u8("optimalUtilizationRate"), - /// The ratio of the loan to the value of the collateral as a percent - BufferLayout.u8("loanToValueRatio"), - /// The percent discount the liquidator gets when buying collateral for an unhealthy obligation - BufferLayout.u8("liquidationBonus"), - /// The percent at which an obligation is considered unhealthy - BufferLayout.u8("liquidationThreshold"), - /// Min borrow APY - BufferLayout.u8("minBorrowRate"), - /// Optimal (utilization) borrow APY - BufferLayout.u8("optimalBorrowRate"), - /// Max borrow APY - BufferLayout.u8("maxBorrowRate"), - ], - "config" - ), + BufferLayout.struct( + [ + /// Optimal utilization rate as a percent + BufferLayout.u8('optimalUtilizationRate'), + /// The ratio of the loan to the value of the collateral as a percent + BufferLayout.u8('loanToValueRatio'), + /// The percent discount the liquidator gets when buying collateral for an unhealthy obligation + BufferLayout.u8('liquidationBonus'), + /// The percent at which an obligation is considered unhealthy + BufferLayout.u8('liquidationThreshold'), + /// Min borrow APY + BufferLayout.u8('minBorrowRate'), + /// Optimal (utilization) borrow APY + BufferLayout.u8('optimalBorrowRate'), + /// Max borrow APY + BufferLayout.u8('maxBorrowRate'), + ], + 'config' + ), - Layout.uint128("cumulativeBorrowRateWad"), - Layout.uint128("borrowedLiquidityWad"), + Layout.uint128('cumulativeBorrowRateWad'), + Layout.uint128('borrowedLiquidityWad'), - Layout.uint64("availableLiquidity"), - Layout.uint64("collateralMintSupply"), - ] -); + Layout.uint64('availableLiquidity'), + Layout.uint64('collateralMintSupply'), +]); export const isLendingReserve = (info: AccountInfo) => { return info.data.length === LendingReserveLayout.span; @@ -86,10 +84,7 @@ export interface LendingReserve { collateralMintSupply: BN; } -export const LendingReserveParser = ( - pubKey: PublicKey, - info: AccountInfo -) => { +export const LendingReserveParser = (pubKey: PublicKey, info: AccountInfo) => { const buffer = Buffer.from(info.data); const data = LendingReserveLayout.decode(buffer); if (data.lastUpdateSlot.toNumber() === 0) return; @@ -123,9 +118,9 @@ export const initReserveInstruction = ( dexMarket: PublicKey // TODO: optional ): TransactionInstruction => { const dataLayout = BufferLayout.struct([ - BufferLayout.u8("instruction"), - Layout.uint64("liquidityAmount"), - BufferLayout.u8("maxUtilizationRate"), + BufferLayout.u8('instruction'), + Layout.uint64('liquidityAmount'), + BufferLayout.u8('maxUtilizationRate'), ]); const data = Buffer.alloc(dataLayout.span); @@ -165,13 +160,8 @@ export const initReserveInstruction = ( }; export const calculateUtilizationRatio = (reserve: LendingReserve) => { - let borrowedLiquidity = wadToLamports( - reserve.borrowedLiquidityWad - ).toNumber(); - return ( - borrowedLiquidity / - (reserve.availableLiquidity.toNumber() + borrowedLiquidity) - ); + let borrowedLiquidity = wadToLamports(reserve.borrowedLiquidityWad).toNumber(); + return borrowedLiquidity / (reserve.availableLiquidity.toNumber() + borrowedLiquidity); }; export const reserveMarketCap = (reserve?: LendingReserve) => { @@ -183,29 +173,15 @@ export const reserveMarketCap = (reserve?: LendingReserve) => { }; export const collateralExchangeRate = (reserve?: LendingReserve) => { - return ( - (reserve?.collateralMintSupply.toNumber() || 1) / reserveMarketCap(reserve) - ); + return (reserve?.collateralMintSupply.toNumber() || 1) / reserveMarketCap(reserve); }; -export const collateralToLiquidity = ( - collateralAmount: BN | number, - reserve?: LendingReserve -) => { - const amount = - typeof collateralAmount === "number" - ? collateralAmount - : collateralAmount.toNumber(); +export const collateralToLiquidity = (collateralAmount: BN | number, reserve?: LendingReserve) => { + const amount = typeof collateralAmount === 'number' ? collateralAmount : collateralAmount.toNumber(); return Math.floor(amount / collateralExchangeRate(reserve)); }; -export const liquidityToCollateral = ( - liquidityAmount: BN | number, - reserve?: LendingReserve -) => { - const amount = - typeof liquidityAmount === "number" - ? liquidityAmount - : liquidityAmount.toNumber(); +export const liquidityToCollateral = (liquidityAmount: BN | number, reserve?: LendingReserve) => { + const amount = typeof liquidityAmount === 'number' ? liquidityAmount : liquidityAmount.toNumber(); return Math.floor(amount * collateralExchangeRate(reserve)); }; diff --git a/src/models/lending/withdraw.ts b/src/models/lending/withdraw.ts index c746ab0..9b9db09 100644 --- a/src/models/lending/withdraw.ts +++ b/src/models/lending/withdraw.ts @@ -1,13 +1,9 @@ -import { - PublicKey, - SYSVAR_CLOCK_PUBKEY, - TransactionInstruction, -} from "@solana/web3.js"; -import BN from "bn.js"; -import * as BufferLayout from "buffer-layout"; -import { LENDING_PROGRAM_ID, TOKEN_PROGRAM_ID } from "../../constants/ids"; -import * as Layout from "./../../utils/layout"; -import { LendingInstruction } from "./lending"; +import { PublicKey, SYSVAR_CLOCK_PUBKEY, TransactionInstruction } from '@solana/web3.js'; +import BN from 'bn.js'; +import * as BufferLayout from 'buffer-layout'; +import { TOKEN_PROGRAM_ID, LENDING_PROGRAM_ID } from '../../utils/ids'; +import * as Layout from './../../utils/layout'; +import { LendingInstruction } from './lending'; export const withdrawInstruction = ( collateralAmount: number | BN, @@ -18,10 +14,7 @@ export const withdrawInstruction = ( reserveSupply: PublicKey, authority: PublicKey ): TransactionInstruction => { - const dataLayout = BufferLayout.struct([ - BufferLayout.u8("instruction"), - Layout.uint64("collateralAmount"), - ]); + const dataLayout = BufferLayout.struct([BufferLayout.u8('instruction'), Layout.uint64('collateralAmount')]); const data = Buffer.alloc(dataLayout.span); dataLayout.encode( diff --git a/src/models/pool.ts b/src/models/pool.ts new file mode 100644 index 0000000..87af094 --- /dev/null +++ b/src/models/pool.ts @@ -0,0 +1,47 @@ +import { PublicKey } from '@solana/web3.js'; +import { TokenAccount } from './account'; + +export const DEFAULT_DENOMINATOR = 10_000; + +export interface PoolInfo { + pubkeys: { + program: PublicKey; + account: PublicKey; + holdingAccounts: PublicKey[]; + holdingMints: PublicKey[]; + mint: PublicKey; + feeAccount?: PublicKey; + }; + legacy: boolean; + raw: any; +} + +export interface LiquidityComponent { + amount: number; + account?: TokenAccount; + mintAddress: string; +} + +export enum CurveType { + ConstantProduct = 0, + ConstantPrice = 1, + Stable = 2, + ConstantProductWithOffset = 3, +} + +export interface PoolConfig { + curveType: CurveType; + fees: { + tradeFeeNumerator: number; + tradeFeeDenominator: number; + ownerTradeFeeNumerator: number; + ownerTradeFeeDenominator: number; + ownerWithdrawFeeNumerator: number; + ownerWithdrawFeeDenominator: number; + hostFeeNumerator: number; + hostFeeDenominator: number; + }; + + token_b_offset?: number; + token_b_price?: number; +} diff --git a/src/models/tokenSwap.ts b/src/models/tokenSwap.ts new file mode 100644 index 0000000..0f7653b --- /dev/null +++ b/src/models/tokenSwap.ts @@ -0,0 +1,438 @@ +import { Numberu64 } from '@solana/spl-token-swap'; +import { PublicKey, Account, TransactionInstruction } from '@solana/web3.js'; +import * as BufferLayout from 'buffer-layout'; +import { programIds } from '../utils/ids'; +import { publicKey, uint64 } from '../utils/layout'; +import { CurveType, PoolConfig } from './pool'; + +export { TokenSwap } from '@solana/spl-token-swap'; + +const FEE_LAYOUT = BufferLayout.struct( + [ + BufferLayout.nu64('tradeFeeNumerator'), + BufferLayout.nu64('tradeFeeDenominator'), + BufferLayout.nu64('ownerTradeFeeNumerator'), + BufferLayout.nu64('ownerTradeFeeDenominator'), + BufferLayout.nu64('ownerWithdrawFeeNumerator'), + BufferLayout.nu64('ownerWithdrawFeeDenominator'), + BufferLayout.nu64('hostFeeNumerator'), + BufferLayout.nu64('hostFeeDenominator'), + ], + 'fees' +); + +export const TokenSwapLayoutLegacyV0 = BufferLayout.struct([ + BufferLayout.u8('isInitialized'), + BufferLayout.u8('nonce'), + publicKey('tokenAccountA'), + publicKey('tokenAccountB'), + publicKey('tokenPool'), + uint64('feesNumerator'), + uint64('feesDenominator'), +]); + +export const TokenSwapLayoutV1: typeof BufferLayout.Structure = BufferLayout.struct([ + BufferLayout.u8('isInitialized'), + BufferLayout.u8('nonce'), + publicKey('tokenProgramId'), + publicKey('tokenAccountA'), + publicKey('tokenAccountB'), + publicKey('tokenPool'), + publicKey('mintA'), + publicKey('mintB'), + publicKey('feeAccount'), + BufferLayout.u8('curveType'), + uint64('tradeFeeNumerator'), + uint64('tradeFeeDenominator'), + uint64('ownerTradeFeeNumerator'), + uint64('ownerTradeFeeDenominator'), + uint64('ownerWithdrawFeeNumerator'), + uint64('ownerWithdrawFeeDenominator'), + BufferLayout.blob(16, 'padding'), +]); + +const CURVE_NODE = BufferLayout.union(BufferLayout.u8(), BufferLayout.blob(32), 'curve'); +CURVE_NODE.addVariant(0, BufferLayout.struct([]), 'constantProduct'); +CURVE_NODE.addVariant(1, BufferLayout.struct([BufferLayout.nu64('token_b_price')]), 'constantPrice'); +CURVE_NODE.addVariant(2, BufferLayout.struct([]), 'stable'); +CURVE_NODE.addVariant(3, BufferLayout.struct([BufferLayout.nu64('token_b_offset')]), 'offset'); + +export const TokenSwapLayout: typeof BufferLayout.Structure = BufferLayout.struct([ + BufferLayout.u8('isInitialized'), + BufferLayout.u8('nonce'), + publicKey('tokenProgramId'), + publicKey('tokenAccountA'), + publicKey('tokenAccountB'), + publicKey('tokenPool'), + publicKey('mintA'), + publicKey('mintB'), + publicKey('feeAccount'), + FEE_LAYOUT, + CURVE_NODE, +]); + +export const createInitSwapInstruction = ( + tokenSwapAccount: Account, + authority: PublicKey, + tokenAccountA: PublicKey, + tokenAccountB: PublicKey, + tokenPool: PublicKey, + feeAccount: PublicKey, + destinationAccount: PublicKey, + tokenProgramId: PublicKey, + swapProgramId: PublicKey, + nonce: number, + config: PoolConfig +): TransactionInstruction => { + const keys = [ + { pubkey: tokenSwapAccount.publicKey, isSigner: false, isWritable: true }, + { pubkey: authority, isSigner: false, isWritable: false }, + { pubkey: tokenAccountA, isSigner: false, isWritable: false }, + { pubkey: tokenAccountB, isSigner: false, isWritable: false }, + { pubkey: tokenPool, isSigner: false, isWritable: true }, + { pubkey: feeAccount, isSigner: false, isWritable: false }, + { pubkey: destinationAccount, isSigner: false, isWritable: true }, + { pubkey: tokenProgramId, isSigner: false, isWritable: false }, + ]; + + let data = Buffer.alloc(1024); + { + const isLatestLayout = programIds().swapLayout === TokenSwapLayout; + if (isLatestLayout) { + const fields = [ + BufferLayout.u8('instruction'), + BufferLayout.u8('nonce'), + BufferLayout.nu64('tradeFeeNumerator'), + BufferLayout.nu64('tradeFeeDenominator'), + BufferLayout.nu64('ownerTradeFeeNumerator'), + BufferLayout.nu64('ownerTradeFeeDenominator'), + BufferLayout.nu64('ownerWithdrawFeeNumerator'), + BufferLayout.nu64('ownerWithdrawFeeDenominator'), + BufferLayout.nu64('hostFeeNumerator'), + BufferLayout.nu64('hostFeeDenominator'), + BufferLayout.u8('curveType'), + ]; + + if (config.curveType === CurveType.ConstantProductWithOffset) { + fields.push(BufferLayout.nu64('token_b_offset')); + fields.push(BufferLayout.blob(24, 'padding')); + } else if (config.curveType === CurveType.ConstantPrice) { + fields.push(BufferLayout.nu64('token_b_price')); + fields.push(BufferLayout.blob(24, 'padding')); + } else { + fields.push(BufferLayout.blob(32, 'padding')); + } + + const commandDataLayout = BufferLayout.struct(fields); + + const { fees, ...rest } = config; + + const encodeLength = commandDataLayout.encode( + { + instruction: 0, // InitializeSwap instruction + nonce, + ...fees, + ...rest, + }, + data + ); + data = data.slice(0, encodeLength); + } else { + const commandDataLayout = BufferLayout.struct([ + BufferLayout.u8('instruction'), + BufferLayout.u8('nonce'), + BufferLayout.u8('curveType'), + BufferLayout.nu64('tradeFeeNumerator'), + BufferLayout.nu64('tradeFeeDenominator'), + BufferLayout.nu64('ownerTradeFeeNumerator'), + BufferLayout.nu64('ownerTradeFeeDenominator'), + BufferLayout.nu64('ownerWithdrawFeeNumerator'), + BufferLayout.nu64('ownerWithdrawFeeDenominator'), + BufferLayout.blob(16, 'padding'), + ]); + + const encodeLength = commandDataLayout.encode( + { + instruction: 0, // InitializeSwap instruction + nonce, + curveType: config.curveType, + tradeFeeNumerator: config.fees.tradeFeeNumerator, + tradeFeeDenominator: config.fees.tradeFeeDenominator, + ownerTradeFeeNumerator: config.fees.ownerTradeFeeNumerator, + ownerTradeFeeDenominator: config.fees.ownerTradeFeeDenominator, + ownerWithdrawFeeNumerator: config.fees.ownerWithdrawFeeNumerator, + ownerWithdrawFeeDenominator: config.fees.ownerWithdrawFeeDenominator, + }, + data + ); + data = data.slice(0, encodeLength); + } + } + + return new TransactionInstruction({ + keys, + programId: swapProgramId, + data, + }); +}; + +export const depositPoolInstruction = ( + tokenSwap: PublicKey, + authority: PublicKey, + sourceA: PublicKey, + sourceB: PublicKey, + intoA: PublicKey, + intoB: PublicKey, + poolToken: PublicKey, + poolAccount: PublicKey, + swapProgramId: PublicKey, + tokenProgramId: PublicKey, + poolTokenAmount: number | Numberu64, + maximumTokenA: number | Numberu64, + maximumTokenB: number | Numberu64 +): TransactionInstruction => { + const dataLayout = BufferLayout.struct([ + BufferLayout.u8('instruction'), + uint64('poolTokenAmount'), + uint64('maximumTokenA'), + uint64('maximumTokenB'), + ]); + + const data = Buffer.alloc(dataLayout.span); + dataLayout.encode( + { + instruction: 2, // Deposit instruction + poolTokenAmount: new Numberu64(poolTokenAmount).toBuffer(), + maximumTokenA: new Numberu64(maximumTokenA).toBuffer(), + maximumTokenB: new Numberu64(maximumTokenB).toBuffer(), + }, + data + ); + + const keys = [ + { pubkey: tokenSwap, isSigner: false, isWritable: false }, + { pubkey: authority, isSigner: false, isWritable: false }, + { pubkey: sourceA, isSigner: false, isWritable: true }, + { pubkey: sourceB, isSigner: false, isWritable: true }, + { pubkey: intoA, isSigner: false, isWritable: true }, + { pubkey: intoB, isSigner: false, isWritable: true }, + { pubkey: poolToken, isSigner: false, isWritable: true }, + { pubkey: poolAccount, isSigner: false, isWritable: true }, + { pubkey: tokenProgramId, isSigner: false, isWritable: false }, + ]; + return new TransactionInstruction({ + keys, + programId: swapProgramId, + data, + }); +}; + +export const depositExactOneInstruction = ( + tokenSwap: PublicKey, + authority: PublicKey, + source: PublicKey, + intoA: PublicKey, + intoB: PublicKey, + poolToken: PublicKey, + poolAccount: PublicKey, + swapProgramId: PublicKey, + tokenProgramId: PublicKey, + sourceTokenAmount: number | Numberu64, + minimumPoolTokenAmount: number | Numberu64 +): TransactionInstruction => { + const dataLayout = BufferLayout.struct([ + BufferLayout.u8('instruction'), + uint64('sourceTokenAmount'), + uint64('minimumPoolTokenAmount'), + ]); + + const data = Buffer.alloc(dataLayout.span); + dataLayout.encode( + { + instruction: 4, // DepositExactOne instruction + sourceTokenAmount: new Numberu64(sourceTokenAmount).toBuffer(), + minimumPoolTokenAmount: new Numberu64(minimumPoolTokenAmount).toBuffer(), + }, + data + ); + + const keys = [ + { pubkey: tokenSwap, isSigner: false, isWritable: false }, + { pubkey: authority, isSigner: false, isWritable: false }, + { pubkey: source, isSigner: false, isWritable: true }, + { pubkey: intoA, isSigner: false, isWritable: true }, + { pubkey: intoB, isSigner: false, isWritable: true }, + { pubkey: poolToken, isSigner: false, isWritable: true }, + { pubkey: poolAccount, isSigner: false, isWritable: true }, + { pubkey: tokenProgramId, isSigner: false, isWritable: false }, + ]; + return new TransactionInstruction({ + keys, + programId: swapProgramId, + data, + }); +}; + +export const withdrawPoolInstruction = ( + tokenSwap: PublicKey, + authority: PublicKey, + poolMint: PublicKey, + feeAccount: PublicKey | undefined, + sourcePoolAccount: PublicKey, + fromA: PublicKey, + fromB: PublicKey, + userAccountA: PublicKey, + userAccountB: PublicKey, + swapProgramId: PublicKey, + tokenProgramId: PublicKey, + poolTokenAmount: number | Numberu64, + minimumTokenA: number | Numberu64, + minimumTokenB: number | Numberu64 +): TransactionInstruction => { + const dataLayout = BufferLayout.struct([ + BufferLayout.u8('instruction'), + uint64('poolTokenAmount'), + uint64('minimumTokenA'), + uint64('minimumTokenB'), + ]); + + const data = Buffer.alloc(dataLayout.span); + dataLayout.encode( + { + instruction: 3, // Withdraw instruction + poolTokenAmount: new Numberu64(poolTokenAmount).toBuffer(), + minimumTokenA: new Numberu64(minimumTokenA).toBuffer(), + minimumTokenB: new Numberu64(minimumTokenB).toBuffer(), + }, + data + ); + + const keys = [ + { pubkey: tokenSwap, isSigner: false, isWritable: false }, + { pubkey: authority, isSigner: false, isWritable: false }, + { pubkey: poolMint, isSigner: false, isWritable: true }, + { pubkey: sourcePoolAccount, isSigner: false, isWritable: true }, + { pubkey: fromA, isSigner: false, isWritable: true }, + { pubkey: fromB, isSigner: false, isWritable: true }, + { pubkey: userAccountA, isSigner: false, isWritable: true }, + { pubkey: userAccountB, isSigner: false, isWritable: true }, + ]; + + if (feeAccount) { + keys.push({ pubkey: feeAccount, isSigner: false, isWritable: true }); + } + keys.push({ pubkey: tokenProgramId, isSigner: false, isWritable: false }); + + return new TransactionInstruction({ + keys, + programId: swapProgramId, + data, + }); +}; + +export const withdrawExactOneInstruction = ( + tokenSwap: PublicKey, + authority: PublicKey, + poolMint: PublicKey, + sourcePoolAccount: PublicKey, + fromA: PublicKey, + fromB: PublicKey, + userAccount: PublicKey, + feeAccount: PublicKey | undefined, + swapProgramId: PublicKey, + tokenProgramId: PublicKey, + sourceTokenAmount: number | Numberu64, + maximumTokenAmount: number | Numberu64 +): TransactionInstruction => { + const dataLayout = BufferLayout.struct([ + BufferLayout.u8('instruction'), + uint64('sourceTokenAmount'), + uint64('maximumTokenAmount'), + ]); + + const data = Buffer.alloc(dataLayout.span); + dataLayout.encode( + { + instruction: 5, // WithdrawExactOne instruction + sourceTokenAmount: new Numberu64(sourceTokenAmount).toBuffer(), + maximumTokenAmount: new Numberu64(maximumTokenAmount).toBuffer(), + }, + data + ); + + const keys = [ + { pubkey: tokenSwap, isSigner: false, isWritable: false }, + { pubkey: authority, isSigner: false, isWritable: false }, + { pubkey: poolMint, isSigner: false, isWritable: true }, + { pubkey: sourcePoolAccount, isSigner: false, isWritable: true }, + { pubkey: fromA, isSigner: false, isWritable: true }, + { pubkey: fromB, isSigner: false, isWritable: true }, + { pubkey: userAccount, isSigner: false, isWritable: true }, + ]; + + if (feeAccount) { + keys.push({ pubkey: feeAccount, isSigner: false, isWritable: true }); + } + keys.push({ pubkey: tokenProgramId, isSigner: false, isWritable: false }); + + return new TransactionInstruction({ + keys, + programId: swapProgramId, + data, + }); +}; + +export const swapInstruction = ( + tokenSwap: PublicKey, + authority: PublicKey, + userSource: PublicKey, + poolSource: PublicKey, + poolDestination: PublicKey, + userDestination: PublicKey, + poolMint: PublicKey, + feeAccount: PublicKey, + swapProgramId: PublicKey, + tokenProgramId: PublicKey, + amountIn: number | Numberu64, + minimumAmountOut: number | Numberu64, + programOwner?: PublicKey +): TransactionInstruction => { + const dataLayout = BufferLayout.struct([ + BufferLayout.u8('instruction'), + uint64('amountIn'), + uint64('minimumAmountOut'), + ]); + + const keys = [ + { pubkey: tokenSwap, isSigner: false, isWritable: false }, + { pubkey: authority, isSigner: false, isWritable: false }, + { pubkey: userSource, isSigner: false, isWritable: true }, + { pubkey: poolSource, isSigner: false, isWritable: true }, + { pubkey: poolDestination, isSigner: false, isWritable: true }, + { pubkey: userDestination, isSigner: false, isWritable: true }, + { pubkey: poolMint, isSigner: false, isWritable: true }, + { pubkey: feeAccount, isSigner: false, isWritable: true }, + { pubkey: tokenProgramId, isSigner: false, isWritable: false }, + ]; + + // optional depending on the build of token-swap program + if (programOwner) { + keys.push({ pubkey: programOwner, isSigner: false, isWritable: true }); + } + + const data = Buffer.alloc(dataLayout.span); + dataLayout.encode( + { + instruction: 1, // Swap instruction + amountIn: new Numberu64(amountIn).toBuffer(), + minimumAmountOut: new Numberu64(minimumAmountOut).toBuffer(), + }, + data + ); + + return new TransactionInstruction({ + keys, + programId: swapProgramId, + data, + }); +}; diff --git a/src/utils/eventEmitter.ts b/src/utils/eventEmitter.ts index 599c1dc..1f9726b 100644 --- a/src/utils/eventEmitter.ts +++ b/src/utils/eventEmitter.ts @@ -1,7 +1,7 @@ -import { EventEmitter as Emitter } from "eventemitter3"; +import { EventEmitter as Emitter } from 'eventemitter3'; export class CacheUpdateEvent { - static type = "CacheUpdate"; + static type = 'CacheUpdate'; id: string; parser: any; isNew: boolean; @@ -12,8 +12,16 @@ export class CacheUpdateEvent { } } +export class CacheDeleteEvent { + static type = 'CacheUpdate'; + id: string; + constructor(id: string) { + this.id = id; + } +} + export class MarketUpdateEvent { - static type = "MarketUpdate"; + static type = 'MarketUpdate'; ids: Set; constructor(ids: Set) { this.ids = ids; @@ -42,4 +50,8 @@ export class EventEmitter { raiseCacheUpdated(id: string, isNew: boolean, parser: any) { this.emitter.emit(CacheUpdateEvent.type, new CacheUpdateEvent(id, isNew, parser)); } + + raiseCacheDeleted(id: string) { + this.emitter.emit(CacheDeleteEvent.type, new CacheDeleteEvent(id)); + } } diff --git a/src/utils/ids.ts b/src/utils/ids.ts new file mode 100644 index 0000000..8cb3dcf --- /dev/null +++ b/src/utils/ids.ts @@ -0,0 +1,96 @@ +import { PublicKey } from '@solana/web3.js'; +import { TokenSwapLayout, TokenSwapLayoutV1 } from '../models'; + +export const WRAPPED_SOL_MINT = new PublicKey('So11111111111111111111111111111111111111112'); +export let TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); + +export let LENDING_PROGRAM_ID = new PublicKey('TokenLend1ng1111111111111111111111111111111'); + +let SWAP_PROGRAM_ID: PublicKey; +let SWAP_PROGRAM_LEGACY_IDS: PublicKey[]; +let SWAP_PROGRAM_LAYOUT: any; + +export const SWAP_PROGRAM_OWNER_FEE_ADDRESS = new PublicKey('HfoTxFR1Tm6kGmWgYWD6J7YHVy1UwqSULUGVLXkJqaKN'); + +export const SWAP_HOST_FEE_ADDRESS = process.env.REACT_APP_SWAP_HOST_FEE_ADDRESS + ? new PublicKey(`${process.env.REACT_APP_SWAP_HOST_FEE_ADDRESS}`) + : SWAP_PROGRAM_OWNER_FEE_ADDRESS; + +export const ENABLE_FEES_INPUT = false; + +console.debug(`Host address: ${SWAP_HOST_FEE_ADDRESS?.toBase58()}`); +console.debug(`Owner address: ${SWAP_PROGRAM_OWNER_FEE_ADDRESS?.toBase58()}`); + +// legacy pools are used to show users contributions in those pools to allow for withdrawals of funds +export const PROGRAM_IDS = [ + { + name: 'mainnet-beta', + swap: () => ({ + current: { + pubkey: new PublicKey('9qvG1zUp8xF1Bi4m6UdRNby1BAAuaDrUxSpv4CmRRMjL'), + layout: TokenSwapLayoutV1, + }, + legacy: [ + // TODO: uncomment to enable legacy contract + // new PublicKey("9qvG1zUp8xF1Bi4m6UdRNby1BAAuaDrUxSpv4CmRRMjL"), + ], + }), + }, + { + name: 'testnet', + swap: () => ({ + current: { + pubkey: new PublicKey('2n2dsFSgmPcZ8jkmBZLGUM2nzuFqcBGQ3JEEj6RJJcEg'), + layout: TokenSwapLayoutV1, + }, + legacy: [], + }), + }, + { + name: 'devnet', + swap: () => ({ + current: { + pubkey: new PublicKey('6Cust2JhvweKLh4CVo1dt21s2PJ86uNGkziudpkNPaCj'), + layout: TokenSwapLayout, + }, + legacy: [new PublicKey('BSfTAcBdqmvX5iE2PW88WFNNp2DHhLUaBKk5WrnxVkcJ')], + }), + }, + { + name: 'localnet', + swap: () => ({ + current: { + pubkey: new PublicKey('369YmCWHGxznT7GGBhcLZDRcRoGWmGKFWdmtiPy78yj7'), + layout: TokenSwapLayoutV1, + }, + legacy: [], + }), + }, +]; + +export const setProgramIds = (envName: string) => { + let instance = PROGRAM_IDS.find((env) => env.name === envName); + if (!instance) { + return; + } + + let swap = instance.swap(); + + SWAP_PROGRAM_ID = swap.current.pubkey; + SWAP_PROGRAM_LAYOUT = swap.current.layout; + SWAP_PROGRAM_LEGACY_IDS = swap.legacy; + + if (envName === 'mainnet-beta') { + LENDING_PROGRAM_ID = new PublicKey('2KfJP7pZ6QSpXa26RmsN6kKVQteDEdQmizLSvuyryeiW'); + } +}; + +export const programIds = () => { + return { + token: TOKEN_PROGRAM_ID, + swap: SWAP_PROGRAM_ID, + swapLayout: SWAP_PROGRAM_LAYOUT, + swap_legacy: SWAP_PROGRAM_LEGACY_IDS, + lending: LENDING_PROGRAM_ID, + }; +}; diff --git a/src/utils/pools.ts b/src/utils/pools.ts new file mode 100644 index 0000000..0a6ded9 --- /dev/null +++ b/src/utils/pools.ts @@ -0,0 +1,1181 @@ +import { Account, Connection, PublicKey, SystemProgram, TransactionInstruction } from '@solana/web3.js'; +import { useEffect, useMemo, useState } from 'react'; +import { Token, MintLayout, AccountLayout } from '@solana/spl-token'; +import { notify } from './notifications'; +import { programIds, SWAP_HOST_FEE_ADDRESS, SWAP_PROGRAM_OWNER_FEE_ADDRESS, WRAPPED_SOL_MINT } from './ids'; +import { + LiquidityComponent, + PoolInfo, + TokenAccount, + createInitSwapInstruction, + TokenSwapLayout, + depositPoolInstruction, + TokenSwapLayoutLegacyV0 as TokenSwapLayoutV0, + TokenSwapLayoutV1, + swapInstruction, + PoolConfig, + depositExactOneInstruction, + withdrawExactOneInstruction, + withdrawPoolInstruction, +} from './../models'; +import { sendTransaction, useConnection } from '../contexts/connection'; +import { cache, getCachedAccount, getMultipleAccounts, TokenAccountParser, useCachedPool } from '../contexts/accounts'; +import { useUserAccounts } from '../hooks/useUserAccounts'; + +const LIQUIDITY_TOKEN_PRECISION = 8; + +export const LIQUIDITY_PROVIDER_FEE = 0.003; +export const SERUM_FEE = 0.0005; + +export const removeLiquidity = async ( + connection: Connection, + wallet: any, + liquidityAmount: number, + account: TokenAccount, + pool?: PoolInfo +) => { + if (!pool) { + throw new Error('Pool is required'); + } + + notify({ + message: 'Removing Liquidity...', + description: 'Please review transactions to approve.', + type: 'warn', + }); + + // TODO get min amounts based on total supply and liquidity + const minAmount0 = 0; + const minAmount1 = 0; + + const poolMint = await cache.queryMint(connection, pool.pubkeys.mint); + const accountA = await cache.query(connection, pool.pubkeys.holdingAccounts[0]); + const accountB = await cache.query(connection, pool.pubkeys.holdingAccounts[1]); + if (!poolMint.mintAuthority) { + throw new Error('Mint doesnt have authority'); + } + const authority = poolMint.mintAuthority; + + const signers: Account[] = []; + const instructions: TransactionInstruction[] = []; + const cleanupInstructions: TransactionInstruction[] = []; + + const accountRentExempt = await connection.getMinimumBalanceForRentExemption(AccountLayout.span); + + const toAccounts: PublicKey[] = [ + await findOrCreateAccountByMint( + wallet.publicKey, + wallet.publicKey, + instructions, + cleanupInstructions, + accountRentExempt, + accountA.info.mint, + signers + ), + await findOrCreateAccountByMint( + wallet.publicKey, + wallet.publicKey, + instructions, + cleanupInstructions, + accountRentExempt, + accountB.info.mint, + signers + ), + ]; + + instructions.push( + Token.createApproveInstruction(programIds().token, account.pubkey, authority, wallet.publicKey, [], liquidityAmount) + ); + + // withdraw + instructions.push( + withdrawPoolInstruction( + pool.pubkeys.account, + authority, + pool.pubkeys.mint, + pool.pubkeys.feeAccount, + account.pubkey, + pool.pubkeys.holdingAccounts[0], + pool.pubkeys.holdingAccounts[1], + toAccounts[0], + toAccounts[1], + pool.pubkeys.program, + programIds().token, + liquidityAmount, + minAmount0, + minAmount1 + ) + ); + + const deleteAccount = liquidityAmount === account.info.amount.toNumber(); + if (deleteAccount) { + instructions.push( + Token.createCloseAccountInstruction(programIds().token, account.pubkey, authority, wallet.publicKey, []) + ); + } + + let tx = await sendTransaction(connection, wallet, instructions.concat(cleanupInstructions), signers); + + if (deleteAccount) { + cache.delete(account.pubkey); + } + + notify({ + message: 'Liquidity Returned. Thank you for your support.', + type: 'success', + description: `Transaction - ${tx}`, + }); + + return [ + accountA.info.mint.equals(WRAPPED_SOL_MINT) ? (wallet.publicKey as PublicKey) : toAccounts[0], + accountB.info.mint.equals(WRAPPED_SOL_MINT) ? (wallet.publicKey as PublicKey) : toAccounts[1], + ]; +}; + +export const removeExactOneLiquidity = async ( + connection: Connection, + wallet: any, + account: TokenAccount, + liquidityAmount: number, + tokenAmount: number, + tokenMint: string, + pool?: PoolInfo +) => { + if (!pool) { + throw new Error('Pool is required'); + } + + notify({ + message: 'Removing Liquidity...', + description: 'Please review transactions to approve.', + type: 'warn', + }); + // Maximum number of LP tokens + // needs to be different math because the new instruction + const liquidityMaxAmount = liquidityAmount * (1 + SLIPPAGE); + + const poolMint = await cache.queryMint(connection, pool.pubkeys.mint); + const accountA = await cache.query(connection, pool.pubkeys.holdingAccounts[0]); + const accountB = await cache.query(connection, pool.pubkeys.holdingAccounts[1]); + if (!poolMint.mintAuthority) { + throw new Error('Mint doesnt have authority'); + } + + const tokenMatchAccount = tokenMint === pool.pubkeys.holdingMints[0].toBase58() ? accountA : accountB; + const authority = poolMint.mintAuthority; + + const signers: Account[] = []; + const instructions: TransactionInstruction[] = []; + const cleanupInstructions: TransactionInstruction[] = []; + + const accountRentExempt = await connection.getMinimumBalanceForRentExemption(AccountLayout.span); + + const toAccount: PublicKey = await findOrCreateAccountByMint( + wallet.publicKey, + wallet.publicKey, + instructions, + cleanupInstructions, + accountRentExempt, + tokenMatchAccount.info.mint, + signers + ); + + instructions.push( + Token.createApproveInstruction( + programIds().token, + account.pubkey, + authority, + wallet.publicKey, + [], + account.info.amount.toNumber() // liquidityAmount <- need math tuning + ) + ); + + // withdraw exact one + instructions.push( + withdrawExactOneInstruction( + pool.pubkeys.account, + authority, + pool.pubkeys.mint, + account.pubkey, + pool.pubkeys.holdingAccounts[0], + pool.pubkeys.holdingAccounts[1], + toAccount, + pool.pubkeys.feeAccount, + pool.pubkeys.program, + programIds().token, + tokenAmount, + liquidityMaxAmount + ) + ); + + let tx = await sendTransaction(connection, wallet, instructions.concat(cleanupInstructions), signers); + + notify({ + message: 'Liquidity Returned. Thank you for your support.', + type: 'success', + description: `Transaction - ${tx}`, + }); + + return tokenMatchAccount.info.mint.equals(WRAPPED_SOL_MINT) ? (wallet.publicKey as PublicKey) : toAccount; +}; + +export const swap = async ( + connection: Connection, + wallet: any, + components: LiquidityComponent[], + SLIPPAGE: number, + pool?: PoolInfo +) => { + if (!pool || !components[0].account) { + notify({ + type: 'error', + message: `Pool doesn't exsist.`, + description: `Swap trade cancelled`, + }); + return; + } + + // Uniswap whitepaper: https://uniswap.org/whitepaper.pdf + // see: https://uniswap.org/docs/v2/advanced-topics/pricing/ + // as well as native uniswap v2 oracle: https://uniswap.org/docs/v2/core-concepts/oracles/ + const amountIn = components[0].amount; // these two should include slippage + const minAmountOut = components[1].amount * (1 - SLIPPAGE); + const holdingA = + pool.pubkeys.holdingMints[0]?.toBase58() === components[0].account.info.mint.toBase58() + ? pool.pubkeys.holdingAccounts[0] + : pool.pubkeys.holdingAccounts[1]; + const holdingB = + holdingA === pool.pubkeys.holdingAccounts[0] ? pool.pubkeys.holdingAccounts[1] : pool.pubkeys.holdingAccounts[0]; + + const poolMint = await cache.queryMint(connection, pool.pubkeys.mint); + if (!poolMint.mintAuthority || !pool.pubkeys.feeAccount) { + throw new Error('Mint doesnt have authority'); + } + const authority = poolMint.mintAuthority; + + const instructions: TransactionInstruction[] = []; + const cleanupInstructions: TransactionInstruction[] = []; + const signers: Account[] = []; + + const accountRentExempt = await connection.getMinimumBalanceForRentExemption(AccountLayout.span); + + const fromAccount = getWrappedAccount( + instructions, + cleanupInstructions, + components[0].account, + wallet.publicKey, + amountIn + accountRentExempt, + signers + ); + + let toAccount = findOrCreateAccountByMint( + wallet.publicKey, + wallet.publicKey, + instructions, + cleanupInstructions, + accountRentExempt, + new PublicKey(components[1].mintAddress), + signers + ); + + // create approval for transfer transactions + instructions.push( + Token.createApproveInstruction(programIds().token, fromAccount, authority, wallet.publicKey, [], amountIn) + ); + + let hostFeeAccount = SWAP_HOST_FEE_ADDRESS + ? findOrCreateAccountByMint( + wallet.publicKey, + SWAP_HOST_FEE_ADDRESS, + instructions, + cleanupInstructions, + accountRentExempt, + pool.pubkeys.mint, + signers + ) + : undefined; + + // swap + instructions.push( + swapInstruction( + pool.pubkeys.account, + authority, + fromAccount, + holdingA, + holdingB, + toAccount, + pool.pubkeys.mint, + pool.pubkeys.feeAccount, + pool.pubkeys.program, + programIds().token, + amountIn, + minAmountOut, + hostFeeAccount + ) + ); + + let tx = await sendTransaction(connection, wallet, instructions.concat(cleanupInstructions), signers); + + notify({ + message: 'Trade executed.', + type: 'success', + description: `Transaction - ${tx}`, + }); +}; + +export const addLiquidity = async ( + connection: Connection, + wallet: any, + components: LiquidityComponent[], + slippage: number, + pool?: PoolInfo, + options?: PoolConfig, + depositType: string = 'both' +) => { + if (depositType === 'one' && pool) { + await _addLiquidityExactOneExistingPool(pool, components[0], connection, wallet); + } else if (!pool) { + if (!options) { + throw new Error('Options are required to create new pool.'); + } + + await _addLiquidityNewPool(wallet, connection, components, options); + } else { + await _addLiquidityExistingPool(pool, components, connection, wallet); + } +}; + +const getHoldings = (connection: Connection, accounts: string[]) => { + return accounts.map((acc) => cache.query(connection, new PublicKey(acc))); +}; + +const toPoolInfo = (item: any, program: PublicKey) => { + return { + pubkeys: { + account: item.pubkey, + program: program, + mint: item.data.tokenPool, + holdingMints: [] as PublicKey[], + holdingAccounts: [item.data.tokenAccountA, item.data.tokenAccountB], + }, + legacy: false, + raw: item, + } as PoolInfo; +}; + +export const usePools = () => { + const connection = useConnection(); + const [pools, setPools] = useState([]); + + // initial query + useEffect(() => { + setPools([]); + + const queryPools = async (swapId: PublicKey, isLegacy = false) => { + let poolsArray: PoolInfo[] = []; + (await connection.getProgramAccounts(swapId)) + .filter( + (item) => + item.account.data.length === TokenSwapLayout.span || + item.account.data.length === TokenSwapLayoutV1.span || + item.account.data.length === TokenSwapLayoutV0.span + ) + .map((item) => { + let result = { + data: undefined as any, + account: item.account, + pubkey: item.pubkey, + init: async () => {}, + }; + + const layout = + item.account.data.length === TokenSwapLayout.span + ? TokenSwapLayout + : item.account.data.length === TokenSwapLayoutV1.span + ? TokenSwapLayoutV1 + : TokenSwapLayoutV0; + + // handling of legacy layout can be removed soon... + if (layout === TokenSwapLayoutV0) { + result.data = layout.decode(item.account.data); + let pool = toPoolInfo(result, swapId); + pool.legacy = isLegacy; + poolsArray.push(pool as PoolInfo); + + result.init = async () => { + try { + // TODO: this is not great + // Ideally SwapLayout stores hash of all the mints to make finding of pool for a pair easier + const holdings = await Promise.all( + getHoldings(connection, [result.data.tokenAccountA, result.data.tokenAccountB]) + ); + + pool.pubkeys.holdingMints = [holdings[0].info.mint, holdings[1].info.mint] as PublicKey[]; + } catch (err) { + console.log(err); + } + }; + } else { + result.data = layout.decode(item.account.data); + + let pool = toPoolInfo(result, swapId); + pool.legacy = isLegacy; + pool.pubkeys.feeAccount = result.data.feeAccount; + pool.pubkeys.holdingMints = [result.data.mintA, result.data.mintB] as PublicKey[]; + + poolsArray.push(pool as PoolInfo); + } + + return result; + }); + + const toQuery = poolsArray + .map( + (p) => + [ + ...p.pubkeys.holdingAccounts.map((h) => h.toBase58()), + ...p.pubkeys.holdingMints.map((h) => h.toBase58()), + p.pubkeys.feeAccount?.toBase58(), // used to calculate volume aproximation + p.pubkeys.mint.toBase58(), + ].filter((p) => p) as string[] + ) + .flat(); + + // This will pre-cache all accounts used by pools + // All those accounts are updated whenever there is a change + await getMultipleAccounts(connection, toQuery, 'single').then(({ keys, array }) => { + return array.map((obj, index) => { + const pubKey = keys[index]; + if (obj.data.length === AccountLayout.span) { + return cache.add(pubKey, obj, TokenAccountParser); + } else if (obj.data.length === MintLayout.span) { + if (!cache.getMint(pubKey)) { + return cache.addMint(new PublicKey(pubKey), obj); + } + } + + return obj; + }) as any[]; + }); + + return poolsArray; + }; + Promise.all([queryPools(programIds().swap), ...programIds().swap_legacy.map((leg) => queryPools(leg, true))]).then( + (all) => { + setPools(all.flat()); + } + ); + }, [connection]); + + useEffect(() => { + const subID = connection.onProgramAccountChange( + programIds().swap, + async (info) => { + const id = (info.accountId as unknown) as string; + if (info.accountInfo.data.length === programIds().swapLayout.span) { + const account = info.accountInfo; + const updated = { + data: programIds().swapLayout.decode(account.data), + account: account, + pubkey: new PublicKey(id), + }; + + const index = pools && pools.findIndex((p) => p.pubkeys.account.toBase58() === id); + if (index && index >= 0 && pools) { + // TODO: check if account is empty? + + const filtered = pools.filter((p, i) => i !== index); + setPools([...filtered, toPoolInfo(updated, programIds().swap)]); + } else { + let pool = toPoolInfo(updated, programIds().swap); + + pool.pubkeys.feeAccount = updated.data.feeAccount; + pool.pubkeys.holdingMints = [updated.data.mintA, updated.data.mintB] as PublicKey[]; + + setPools([...pools, pool]); + } + } + }, + 'singleGossip' + ); + + return () => { + connection.removeProgramAccountChangeListener(subID); + }; + }, [connection, pools]); + + return { pools }; +}; + +export const usePoolForBasket = (mints: (string | undefined)[]) => { + const connection = useConnection(); + const { pools } = useCachedPool(); + const [pool, setPool] = useState(); + const sortedMints = useMemo(() => [...mints].sort(), [...mints]); // eslint-disable-line + useEffect(() => { + (async () => { + // reset pool during query + setPool(undefined); + let matchingPool = pools + .filter((p) => !p.legacy) + .filter((p) => + p.pubkeys.holdingMints + .map((a) => a.toBase58()) + .sort() + .every((address, i) => address === sortedMints[i]) + ); + + for (let i = 0; i < matchingPool.length; i++) { + const p = matchingPool[i]; + + const account = await cache.query(connection, p.pubkeys.holdingAccounts[0]); + + if (!account.info.amount.eqn(0)) { + setPool(p); + return; + } + } + })(); + }, [connection, sortedMints, pools]); + + return pool; +}; + +export const useOwnedPools = (legacy = false) => { + const { pools } = useCachedPool(legacy); + const { userAccounts } = useUserAccounts(); + + const ownedPools = useMemo(() => { + const map = userAccounts.reduce((acc, item) => { + const key = item.info.mint.toBase58(); + acc.set(key, [...(acc.get(key) || []), item]); + return acc; + }, new Map()); + + return pools + .filter((p) => map.has(p.pubkeys.mint.toBase58()) && p.legacy === legacy) + .map((item) => { + let feeAccount = item.pubkeys.feeAccount?.toBase58(); + return map.get(item.pubkeys.mint.toBase58())?.map((a) => { + return { + account: a as TokenAccount, + isFeeAccount: feeAccount === a.pubkey.toBase58(), + pool: item, + }; + }) as { + account: TokenAccount; + isFeeAccount: boolean; + pool: PoolInfo; + }[]; + }) + .flat(); + }, [pools, userAccounts, legacy]); + + return ownedPools; +}; + +// Allow for this much price movement in the pool before adding liquidity to the pool aborts +const SLIPPAGE = 0.005; + +async function _addLiquidityExistingPool( + pool: PoolInfo, + components: LiquidityComponent[], + connection: Connection, + wallet: any +) { + notify({ + message: 'Adding Liquidity...', + description: 'Please review transactions to approve.', + type: 'warn', + }); + + const poolMint = await cache.queryMint(connection, pool.pubkeys.mint); + if (!poolMint.mintAuthority) { + throw new Error('Mint doesnt have authority'); + } + + if (!pool.pubkeys.feeAccount) { + throw new Error('Invald fee account'); + } + + const accountA = await cache.query(connection, pool.pubkeys.holdingAccounts[0]); + const accountB = await cache.query(connection, pool.pubkeys.holdingAccounts[1]); + + const reserve0 = accountA.info.amount.toNumber(); + const reserve1 = accountB.info.amount.toNumber(); + const fromA = accountA.info.mint.toBase58() === components[0].mintAddress ? components[0] : components[1]; + const fromB = fromA === components[0] ? components[1] : components[0]; + + if (!fromA.account || !fromB.account) { + throw new Error('Missing account info.'); + } + + const supply = poolMint.supply.toNumber(); + const authority = poolMint.mintAuthority; + + // Uniswap whitepaper: https://uniswap.org/whitepaper.pdf + // see: https://uniswap.org/docs/v2/advanced-topics/pricing/ + // as well as native uniswap v2 oracle: https://uniswap.org/docs/v2/core-concepts/oracles/ + const amount0 = fromA.amount; + const amount1 = fromB.amount; + + const liquidity = Math.min( + (amount0 * (1 - SLIPPAGE) * supply) / reserve0, + (amount1 * (1 - SLIPPAGE) * supply) / reserve1 + ); + const instructions: TransactionInstruction[] = []; + const cleanupInstructions: TransactionInstruction[] = []; + + const signers: Account[] = []; + + const accountRentExempt = await connection.getMinimumBalanceForRentExemption(AccountLayout.span); + const fromKeyA = getWrappedAccount( + instructions, + cleanupInstructions, + fromA.account, + wallet.publicKey, + amount0 + accountRentExempt, + signers + ); + const fromKeyB = getWrappedAccount( + instructions, + cleanupInstructions, + fromB.account, + wallet.publicKey, + amount1 + accountRentExempt, + signers + ); + + let toAccount = findOrCreateAccountByMint( + wallet.publicKey, + wallet.publicKey, + instructions, + [], + accountRentExempt, + pool.pubkeys.mint, + signers, + new Set([pool.pubkeys.feeAccount.toBase58()]) + ); + + // create approval for transfer transactions + instructions.push( + Token.createApproveInstruction(programIds().token, fromKeyA, authority, wallet.publicKey, [], amount0) + ); + + instructions.push( + Token.createApproveInstruction(programIds().token, fromKeyB, authority, wallet.publicKey, [], amount1) + ); + + // deposit + instructions.push( + depositPoolInstruction( + pool.pubkeys.account, + authority, + fromKeyA, + fromKeyB, + pool.pubkeys.holdingAccounts[0], + pool.pubkeys.holdingAccounts[1], + pool.pubkeys.mint, + toAccount, + pool.pubkeys.program, + programIds().token, + liquidity, + amount0, + amount1 + ) + ); + + let tx = await sendTransaction(connection, wallet, instructions.concat(cleanupInstructions), signers); + + notify({ + message: 'Pool Funded. Happy trading.', + type: 'success', + description: `Transaction - ${tx}`, + }); +} + +async function _addLiquidityExactOneExistingPool( + pool: PoolInfo, + component: LiquidityComponent, + connection: Connection, + wallet: any +) { + notify({ + message: 'Adding Liquidity...', + description: 'Please review transactions to approve.', + type: 'warn', + }); + + const poolMint = await cache.queryMint(connection, pool.pubkeys.mint); + if (!poolMint.mintAuthority) { + throw new Error('Mint doesnt have authority'); + } + + if (!pool.pubkeys.feeAccount) { + throw new Error('Invald fee account'); + } + + const accountA = await cache.query(connection, pool.pubkeys.holdingAccounts[0]); + const accountB = await cache.query(connection, pool.pubkeys.holdingAccounts[1]); + + const from = component; + + if (!from.account) { + throw new Error('Missing account info.'); + } + const reserve = + accountA.info.mint.toBase58() === from.mintAddress + ? accountA.info.amount.toNumber() + : accountB.info.amount.toNumber(); + + const supply = poolMint.supply.toNumber(); + const authority = poolMint.mintAuthority; + + // Uniswap whitepaper: https://uniswap.org/whitepaper.pdf + // see: https://uniswap.org/docs/v2/advanced-topics/pricing/ + // as well as native uniswap v2 oracle: https://uniswap.org/docs/v2/core-concepts/oracles/ + const amount = from.amount; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _liquidityTokenTempMath = (amount * (1 - SLIPPAGE) * supply) / reserve; + const liquidityToken = 0; + + const instructions: TransactionInstruction[] = []; + const cleanupInstructions: TransactionInstruction[] = []; + + const signers: Account[] = []; + + const accountRentExempt = await connection.getMinimumBalanceForRentExemption(AccountLayout.span); + const fromKey = getWrappedAccount( + instructions, + cleanupInstructions, + from.account, + wallet.publicKey, + amount + accountRentExempt, + signers + ); + + let toAccount = findOrCreateAccountByMint( + wallet.publicKey, + wallet.publicKey, + instructions, + [], + accountRentExempt, + pool.pubkeys.mint, + signers, + new Set([pool.pubkeys.feeAccount.toBase58()]) + ); + + // create approval for transfer transactions + instructions.push( + Token.createApproveInstruction(programIds().token, fromKey, authority, wallet.publicKey, [], amount) + ); + + // deposit + instructions.push( + depositExactOneInstruction( + pool.pubkeys.account, + authority, + fromKey, + pool.pubkeys.holdingAccounts[0], + pool.pubkeys.holdingAccounts[1], + pool.pubkeys.mint, + toAccount, + pool.pubkeys.program, + programIds().token, + amount, + liquidityToken + ) + ); + + let tx = await sendTransaction(connection, wallet, instructions.concat(cleanupInstructions), signers); + + notify({ + message: 'Pool Funded. Happy trading.', + type: 'success', + description: `Transaction - ${tx}`, + }); +} + +function findOrCreateAccountByMint( + payer: PublicKey, + owner: PublicKey, + instructions: TransactionInstruction[], + cleanupInstructions: TransactionInstruction[], + accountRentExempt: number, + mint: PublicKey, // use to identify same type + signers: Account[], + excluded?: Set +): PublicKey { + const accountToFind = mint.toBase58(); + const account = getCachedAccount( + (acc) => + acc.info.mint.toBase58() === accountToFind && + acc.info.owner.toBase58() === owner.toBase58() && + (excluded === undefined || !excluded.has(acc.pubkey.toBase58())) + ); + const isWrappedSol = accountToFind === WRAPPED_SOL_MINT.toBase58(); + + let toAccount: PublicKey; + if (account && !isWrappedSol) { + toAccount = account.pubkey; + } else { + // creating depositor pool account + const newToAccount = createSplAccount(instructions, payer, accountRentExempt, mint, owner, AccountLayout.span); + + toAccount = newToAccount.publicKey; + signers.push(newToAccount); + + if (isWrappedSol) { + cleanupInstructions.push(Token.createCloseAccountInstruction(programIds().token, toAccount, payer, payer, [])); + } + } + + return toAccount; +} + +function estimateProceedsFromInput( + inputQuantityInPool: number, + proceedsQuantityInPool: number, + inputAmount: number +): number { + return (proceedsQuantityInPool * inputAmount) / (inputQuantityInPool + inputAmount); +} + +function estimateInputFromProceeds( + inputQuantityInPool: number, + proceedsQuantityInPool: number, + proceedsAmount: number +): number | string { + if (proceedsAmount >= proceedsQuantityInPool) { + return 'Not possible'; + } + + return (inputQuantityInPool * proceedsAmount) / (proceedsQuantityInPool - proceedsAmount); +} + +export enum PoolOperation { + Add, + SwapGivenInput, + SwapGivenProceeds, +} + +export async function calculateDependentAmount( + connection: Connection, + independent: string, + amount: number, + pool: PoolInfo, + op: PoolOperation +): Promise { + const poolMint = await cache.queryMint(connection, pool.pubkeys.mint); + const accountA = await cache.query(connection, pool.pubkeys.holdingAccounts[0]); + const amountA = accountA.info.amount.toNumber(); + + const accountB = await cache.query(connection, pool.pubkeys.holdingAccounts[1]); + let amountB = accountB.info.amount.toNumber(); + + if (!poolMint.mintAuthority) { + throw new Error('Mint doesnt have authority'); + } + + if (poolMint.supply.eqn(0)) { + return; + } + + let offsetAmount = 0; + const offsetCurve = pool.raw?.data?.curve?.offset; + if (offsetCurve) { + offsetAmount = offsetCurve.token_b_offset; + amountB = amountB + offsetAmount; + } + + const mintA = await cache.queryMint(connection, accountA.info.mint); + const mintB = await cache.queryMint(connection, accountB.info.mint); + + if (!mintA || !mintB) { + return; + } + + const isFirstIndependent = accountA.info.mint.toBase58() === independent; + const depPrecision = Math.pow(10, isFirstIndependent ? mintB.decimals : mintA.decimals); + const indPrecision = Math.pow(10, isFirstIndependent ? mintA.decimals : mintB.decimals); + const indAdjustedAmount = amount * indPrecision; + + let indBasketQuantity = isFirstIndependent ? amountA : amountB; + + let depBasketQuantity = isFirstIndependent ? amountB : amountA; + + var depAdjustedAmount; + + const constantPrice = pool.raw?.data?.curve?.constantPrice; + if (constantPrice) { + debugger; + depAdjustedAmount = (amount * depPrecision) / constantPrice.token_b_price; + } else { + switch (+op) { + case PoolOperation.Add: + depAdjustedAmount = (depBasketQuantity / indBasketQuantity) * indAdjustedAmount; + break; + case PoolOperation.SwapGivenProceeds: + depAdjustedAmount = estimateInputFromProceeds(depBasketQuantity, indBasketQuantity, indAdjustedAmount); + break; + case PoolOperation.SwapGivenInput: + depAdjustedAmount = estimateProceedsFromInput(indBasketQuantity, depBasketQuantity, indAdjustedAmount); + break; + } + } + + if (typeof depAdjustedAmount === 'string') { + return depAdjustedAmount; + } + if (depAdjustedAmount === undefined) { + return undefined; + } + return depAdjustedAmount / depPrecision; +} + +// TODO: add ui to customize curve type +async function _addLiquidityNewPool( + wallet: any, + connection: Connection, + components: LiquidityComponent[], + options: PoolConfig +) { + notify({ + message: 'Creating new pool...', + description: 'Please review transactions to approve.', + type: 'warn', + }); + + if (components.some((c) => !c.account)) { + notify({ + message: 'You need to have balance for all legs in the basket...', + description: 'Please review inputs.', + type: 'error', + }); + return; + } + + let instructions: TransactionInstruction[] = []; + let cleanupInstructions: TransactionInstruction[] = []; + + const liquidityTokenMint = new Account(); + // Create account for pool liquidity token + instructions.push( + SystemProgram.createAccount({ + fromPubkey: wallet.publicKey, + newAccountPubkey: liquidityTokenMint.publicKey, + lamports: await connection.getMinimumBalanceForRentExemption(MintLayout.span), + space: MintLayout.span, + programId: programIds().token, + }) + ); + + const tokenSwapAccount = new Account(); + + const [authority, nonce] = await PublicKey.findProgramAddress( + [tokenSwapAccount.publicKey.toBuffer()], + programIds().swap + ); + + // create mint for pool liquidity token + instructions.push( + Token.createInitMintInstruction( + programIds().token, + liquidityTokenMint.publicKey, + LIQUIDITY_TOKEN_PRECISION, + // pass control of liquidity mint to swap program + authority, + // swap program can freeze liquidity token mint + null + ) + ); + + // Create holding accounts for + const accountRentExempt = await connection.getMinimumBalanceForRentExemption(AccountLayout.span); + const holdingAccounts: Account[] = []; + let signers: Account[] = []; + + components.forEach((leg) => { + if (!leg.account) { + return; + } + + const mintPublicKey = leg.account.info.mint; + // component account to store tokens I of N in liquidity poll + holdingAccounts.push( + createSplAccount(instructions, wallet.publicKey, accountRentExempt, mintPublicKey, authority, AccountLayout.span) + ); + }); + + // creating depositor pool account + const depositorAccount = createSplAccount( + instructions, + wallet.publicKey, + accountRentExempt, + liquidityTokenMint.publicKey, + wallet.publicKey, + AccountLayout.span + ); + + // creating fee pool account its set from env variable or to creater of the pool + // creater of the pool is not allowed in some versions of token-swap program + const feeAccount = createSplAccount( + instructions, + wallet.publicKey, + accountRentExempt, + liquidityTokenMint.publicKey, + SWAP_PROGRAM_OWNER_FEE_ADDRESS || wallet.publicKey, + AccountLayout.span + ); + + // create all accounts in one transaction + let tx = await sendTransaction(connection, wallet, instructions, [ + liquidityTokenMint, + depositorAccount, + feeAccount, + ...holdingAccounts, + ...signers, + ]); + + notify({ + message: 'Accounts created', + description: `Transaction ${tx}`, + type: 'success', + }); + + notify({ + message: 'Adding Liquidity...', + description: 'Please review transactions to approve.', + type: 'warn', + }); + + signers = []; + instructions = []; + cleanupInstructions = []; + + instructions.push( + SystemProgram.createAccount({ + fromPubkey: wallet.publicKey, + newAccountPubkey: tokenSwapAccount.publicKey, + lamports: await connection.getMinimumBalanceForRentExemption(programIds().swapLayout.span), + space: programIds().swapLayout.span, + programId: programIds().swap, + }) + ); + + components.forEach((leg, i) => { + if (!leg.account) { + return; + } + + // create temporary account for wrapped sol to perform transfer + const from = getWrappedAccount( + instructions, + cleanupInstructions, + leg.account, + wallet.publicKey, + leg.amount + accountRentExempt, + signers + ); + + instructions.push( + Token.createTransferInstruction( + programIds().token, + from, + holdingAccounts[i].publicKey, + wallet.publicKey, + [], + leg.amount + ) + ); + }); + + instructions.push( + createInitSwapInstruction( + tokenSwapAccount, + authority, + holdingAccounts[0].publicKey, + holdingAccounts[1].publicKey, + liquidityTokenMint.publicKey, + feeAccount.publicKey, + depositorAccount.publicKey, + programIds().token, + programIds().swap, + nonce, + options + ) + ); + + // All instructions didn't fit in single transaction + // initialize and provide inital liquidity to swap in 2nd (this prevents loss of funds) + tx = await sendTransaction(connection, wallet, instructions.concat(cleanupInstructions), [ + tokenSwapAccount, + ...signers, + ]); + + notify({ + message: 'Pool Funded. Happy trading.', + type: 'success', + description: `Transaction - ${tx}`, + }); +} + +function getWrappedAccount( + instructions: TransactionInstruction[], + cleanupInstructions: TransactionInstruction[], + toCheck: TokenAccount, + payer: PublicKey, + amount: number, + signers: Account[] +) { + if (!toCheck.info.isNative) { + return toCheck.pubkey; + } + + const account = new Account(); + instructions.push( + SystemProgram.createAccount({ + fromPubkey: payer, + newAccountPubkey: account.publicKey, + lamports: amount, + space: AccountLayout.span, + programId: programIds().token, + }) + ); + + instructions.push(Token.createInitAccountInstruction(programIds().token, WRAPPED_SOL_MINT, account.publicKey, payer)); + + cleanupInstructions.push( + Token.createCloseAccountInstruction(programIds().token, account.publicKey, payer, payer, []) + ); + + signers.push(account); + + return account.publicKey; +} + +function createSplAccount( + instructions: TransactionInstruction[], + payer: PublicKey, + accountRentExempt: number, + mint: PublicKey, + owner: PublicKey, + space: number +) { + const account = new Account(); + instructions.push( + SystemProgram.createAccount({ + fromPubkey: payer, + newAccountPubkey: account.publicKey, + lamports: accountRentExempt, + space, + programId: programIds().token, + }) + ); + + instructions.push(Token.createInitAccountInstruction(programIds().token, mint, account.publicKey, owner)); + + return account; +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 92f2b49..43ec1e4 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,10 +1,10 @@ -import { useCallback, useState } from "react"; -import { MintInfo } from "@solana/spl-token"; +import { useCallback, useState } from 'react'; +import { MintInfo } from '@solana/spl-token'; -import { TokenAccount } from "./../models"; -import { PublicKey } from "@solana/web3.js"; -import BN from "bn.js"; -import { WAD, ZERO } from "../constants"; +import { PoolInfo, TokenAccount } from './../models'; +import { PublicKey } from '@solana/web3.js'; +import BN from 'bn.js'; +import { WAD, ZERO } from '../constants'; export interface KnownToken { tokenSymbol: string; @@ -49,15 +49,11 @@ export function shortenAddress(address: string, chars = 4): string { return `${address.slice(0, chars)}...${address.slice(-chars)}`; } -export function getTokenName( - map: KnownTokenMap, - mint?: string | PublicKey, - shorten = true -): string { +export function getTokenName(map: KnownTokenMap, mint?: string | PublicKey, shorten = true): string { const mintAddress = typeof mint === 'string' ? mint : mint?.toBase58(); if (!mintAddress) { - return "N/A"; + return 'N/A'; } const knownSymbol = map.get(mintAddress)?.tokenSymbol; @@ -68,12 +64,8 @@ export function getTokenName( return shorten ? `${mintAddress.substring(0, 5)}...` : mintAddress; } -export function getTokenIcon( - map: KnownTokenMap, - mintAddress?: string | PublicKey -): string | undefined { - const address = - typeof mintAddress === "string" ? mintAddress : mintAddress?.toBase58(); +export function getTokenIcon(map: KnownTokenMap, mintAddress?: string | PublicKey): string | undefined { + const address = typeof mintAddress === 'string' ? mintAddress : mintAddress?.toBase58(); if (!address) { return; } @@ -85,25 +77,20 @@ export function isKnownMint(map: KnownTokenMap, mintAddress: string) { return !!map.get(mintAddress); } -export const STABLE_COINS = new Set(["USDC", "wUSDC", "USDT"]); +export const STABLE_COINS = new Set(['USDC', 'wUSDC', 'USDT']); export function chunks(array: T[], size: number): T[][] { - return Array.apply( - 0, - new Array(Math.ceil(array.length / size)) - ).map((_, index) => array.slice(index * size, (index + 1) * size)); + return Array.apply(0, new Array(Math.ceil(array.length / size))).map((_, index) => + array.slice(index * size, (index + 1) * size) + ); } -export function toLamports( - account?: TokenAccount | number, - mint?: MintInfo -): number { +export function toLamports(account?: TokenAccount | number, mint?: MintInfo): number { if (!account) { return 0; } - const amount = - typeof account === "number" ? account : account.info.amount?.toNumber(); + const amount = typeof account === 'number' ? account : account.info.amount?.toNumber(); const precision = Math.pow(10, mint?.decimals || 0); return Math.floor(amount * precision); @@ -113,28 +100,20 @@ export function wadToLamports(amount?: BN): BN { return amount?.div(WAD) || ZERO; } -export function fromLamports( - account?: TokenAccount | number | BN, - mint?: MintInfo, - rate: number = 1.0 -): number { +export function fromLamports(account?: TokenAccount | number | BN, mint?: MintInfo, rate: number = 1.0): number { if (!account) { return 0; } const amount = Math.floor( - typeof account === "number" - ? account - : BN.isBN(account) - ? account.toNumber() - : account.info.amount.toNumber() + typeof account === 'number' ? account : BN.isBN(account) ? account.toNumber() : account.info.amount.toNumber() ); const precision = Math.pow(10, mint?.decimals || 0); return (amount / precision) * rate; } -var SI_SYMBOL = ["", "k", "M", "G", "T", "P", "E"]; +var SI_SYMBOL = ['', 'k', 'M', 'G', 'T', 'P', 'E']; const abbreviateNumber = (number: number, precision: number) => { let tier = (Math.log10(number) / 3) | 0; @@ -155,29 +134,25 @@ export function formatTokenAmount( account?: TokenAccount, mint?: MintInfo, rate: number = 1.0, - prefix = "", - suffix = "", + prefix = '', + suffix = '', precision = 6, abbr = false ): string { if (!account) { - return ""; + return ''; } - return `${[prefix]}${format( - fromLamports(account, mint, rate), - precision, - abbr - )}${suffix}`; + return `${[prefix]}${format(fromLamports(account, mint, rate), precision, abbr)}${suffix}`; } -export const formatUSD = new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", +export const formatUSD = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', }); -const numberFormater = new Intl.NumberFormat("en-US", { - style: "decimal", +const numberFormater = new Intl.NumberFormat('en-US', { + style: 'decimal', minimumFractionDigits: 2, maximumFractionDigits: 2, }); @@ -185,15 +160,33 @@ const numberFormater = new Intl.NumberFormat("en-US", { export const formatNumber = { format: (val?: number) => { if (!val) { - return "--"; + return '--'; } return numberFormater.format(val); }, }; -export const formatPct = new Intl.NumberFormat("en-US", { - style: "percent", +export const formatPct = new Intl.NumberFormat('en-US', { + style: 'percent', minimumFractionDigits: 2, maximumFractionDigits: 2, }); + +export function convert(account?: TokenAccount | number, mint?: MintInfo, rate: number = 1.0): number { + if (!account) { + return 0; + } + + const amount = typeof account === 'number' ? account : account.info.amount?.toNumber(); + + const precision = Math.pow(10, mint?.decimals || 0); + let result = (amount / precision) * rate; + + return result; +} + +export function getPoolName(map: KnownTokenMap, pool: PoolInfo, shorten = true) { + const sorted = pool.pubkeys.holdingMints.map((a) => a.toBase58()).sort(); + return sorted.map((item) => getTokenName(map, item, shorten)).join('/'); +} diff --git a/src/views/marginTrading/newPosition/Breakdown.tsx b/src/views/marginTrading/newPosition/Breakdown.tsx index 0eed231..9132f77 100644 --- a/src/views/marginTrading/newPosition/Breakdown.tsx +++ b/src/views/marginTrading/newPosition/Breakdown.tsx @@ -2,6 +2,7 @@ import { Progress, Slider, Card, Statistic } from 'antd'; import React, { useState } from 'react'; import { Position } from './interfaces'; import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'; +import tokens from '../../../config/tokens.json'; export function Breakdown({ item }: { item: Position }) { let myPart = (item.asset?.value || 0) / item.leverage; @@ -10,6 +11,7 @@ export function Breakdown({ item }: { item: Position }) { const myColor = 'blue'; const gains = 'green'; const losses = 'red'; + const token = tokens.find((t) => t.mintAddress === item.asset.type?.info?.liquidityMint?.toBase58()); const [myGain, setMyGain] = useState(0); const profitPart = (myPart + brokeragePart) * (myGain / 100); @@ -64,7 +66,7 @@ export function Breakdown({ item }: { item: Position }) { value={brokeragePart} precision={2} valueStyle={{ color: brokerageColor }} - suffix={item.asset?.type?.tokenName} + suffix={token?.tokenSymbol} /> @@ -73,7 +75,7 @@ export function Breakdown({ item }: { item: Position }) { value={myPart} precision={2} valueStyle={{ color: myColor }} - suffix={item.asset?.type?.tokenName} + suffix={token?.tokenSymbol} /> @@ -82,7 +84,7 @@ export function Breakdown({ item }: { item: Position }) { value={profitPart} precision={2} valueStyle={{ color: profitPart > 0 ? gains : losses }} - suffix={item.asset?.type?.tokenSymbol} + suffix={token?.tokenSymbol} prefix={profitPart > 0 ? : } /> diff --git a/src/views/marginTrading/newPosition/NewPositionForm.tsx b/src/views/marginTrading/newPosition/NewPositionForm.tsx index c628c3d..e7ef87f 100644 --- a/src/views/marginTrading/newPosition/NewPositionForm.tsx +++ b/src/views/marginTrading/newPosition/NewPositionForm.tsx @@ -1,15 +1,17 @@ import { Button, Card, Radio } from 'antd'; -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { ActionConfirmation } from '../../../components/ActionConfirmation'; import { NumericInput } from '../../../components/Input/numeric'; import { TokenIcon } from '../../../components/TokenIcon'; import { LABELS } from '../../../constants'; import { cache, ParsedAccount } from '../../../contexts/accounts'; -import { LendingReserve, LendingReserveParser } from '../../../models/lending/reserve'; +import { collateralToLiquidity, LendingReserve, LendingReserveParser } from '../../../models/lending/reserve'; import { Position } from './interfaces'; import tokens from '../../../config/tokens.json'; import { CollateralSelector } from '../../../components/CollateralSelector'; import { Breakdown } from './Breakdown'; +import { usePoolForBasket } from '../../../utils/pools'; +import { useEnrichedPools } from '../../../contexts/market'; interface NewPositionFormProps { lendingReserve: ParsedAccount; @@ -26,6 +28,66 @@ export default function NewPositionForm({ lendingReserve, newPosition, setNewPos height: '100%', }; const [showConfirmation, setShowConfirmation] = useState(false); + + const collType = newPosition.collateral; + const desiredType = newPosition.asset.type; + + const pool = usePoolForBasket([ + collType?.info?.liquidityMint?.toBase58(), + desiredType?.info?.liquidityMint?.toBase58(), + ]); + + const enriched = useEnrichedPools(pool ? [pool] : []); + + // Leverage validation - if you choose this leverage, is it allowable, with your buying power and with + // the pool we have to cover you? + useEffect(() => { + if (!collType || !desiredType || !newPosition.asset.value || !enriched || enriched.length == 0) { + return; + } + + const amountDesiredToPurchase = newPosition.asset.value; + const leverageDesired = newPosition.leverage; + console.log('collateral reserve', collType); + const amountAvailableInOysterForMargin = collateralToLiquidity(collType.info.availableLiquidity, desiredType.info); + const amountToDepositOnMargin = amountDesiredToPurchase / leverageDesired; + console.log( + 'Amount desired', + amountDesiredToPurchase, + 'leverage', + leverageDesired, + 'amountAvailable', + amountAvailableInOysterForMargin, + ' amount to deposit on margin', + amountToDepositOnMargin + ); + if (amountToDepositOnMargin > amountAvailableInOysterForMargin) { + setNewPosition({ ...newPosition, error: LABELS.NOT_ENOUGH_MARGIN_MESSAGE }); + return; + } + + const liqA = enriched[0].liquidityA; + const liqB = enriched[0].liquidityB; + const supplyRatio = liqA / liqB; + + console.log('Liq A', liqA, 'liq b', liqB, 'supply ratio', supplyRatio); + + // change in liquidity is amount desired (in units of B) converted to collateral units(A) + const chgLiqA = collateralToLiquidity(amountDesiredToPurchase, collType.info); + const newLiqA = liqA - chgLiqA; + const newLiqB = liqB + amountDesiredToPurchase; + const newSupplyRatio = newLiqA / newLiqB; // 75 / 100 + console.log('chg in liq a', chgLiqA, 'new liq a', newLiqA, 'new supply ratio', newSupplyRatio); + const priceImpact = Math.abs(100 - 100 * (newSupplyRatio / supplyRatio)); // abs(100 - 100*(0.75 / 1)) = 25% + const marginToLeverage = 100 / leverageDesired; + console.log('priceImpact', priceImpact, 'marginToLeverage', marginToLeverage); + if (marginToLeverage > priceImpact) { + // if their marginToLeverage ratio < priceImpact, we say hey ho no go + setNewPosition({ ...newPosition, error: LABELS.LEVERAGE_LIMIT_MESSAGE }); + return; + } + }, [collType, desiredType, newPosition.asset.value, newPosition.leverage, enriched]); + return ( {showConfirmation ? ( @@ -44,14 +106,13 @@ export default function NewPositionForm({ lendingReserve, newPosition, setNewPos onCollateralReserve={(key) => { const id: string = cache.byParser(LendingReserveParser).find((acc) => acc === key) || ''; const parser = cache.get(id) as ParsedAccount; - const tokenMint = parser.info.collateralMint.toBase58(); - setNewPosition({ ...newPosition, collateral: tokens.find((t) => t.mintAddress === tokenMint) }); + setNewPosition({ ...newPosition, collateral: parser }); }} />
{LABELS.MARGIN_TRADE_QUESTION}
- + -
{newPosition.asset.type?.tokenSymbol}
+
+ { + tokens.find((t) => t.mintAddress === newPosition.asset.type?.info?.liquidityMint?.toBase58()) + ?.tokenSymbol + } +
@@ -94,6 +160,7 @@ export default function NewPositionForm({ lendingReserve, newPosition, setNewPos setNewPosition({ ...newPosition, leverage }); }} /> +

{newPosition.error}

+ {progressBar}
); diff --git a/src/views/marginTrading/newPosition/GainsChart.tsx b/src/views/marginTrading/newPosition/GainsChart.tsx new file mode 100644 index 0000000..f98f463 --- /dev/null +++ b/src/views/marginTrading/newPosition/GainsChart.tsx @@ -0,0 +1,230 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Line } from 'react-chartjs-2'; +import { Position } from './interfaces'; + +// Special thanks to +// https://github.com/bZxNetwork/fulcrum_ui/blob/development/packages/fulcrum-website/assets/js/trading.js +// For the basis of this code - I copied it directly from there and then modified it for our needs. +// You guys are real heroes - that is beautifully done. +const baseData = [ + { x: 0, y: 65 }, + { x: 1, y: 80 }, + { x: 2, y: 60 }, + { x: 3, y: 30 }, + { x: 4, y: 20 }, + { x: 5, y: 35 }, + { x: 6, y: 25 }, + { x: 7, y: 40 }, + { x: 8, y: 36 }, + { x: 9, y: 34 }, + { x: 10, y: 50 }, + { x: 11, y: 33 }, + { x: 12, y: 37 }, + { x: 13, y: 45 }, + { x: 14, y: 35 }, + { x: 15, y: 37 }, + { x: 16, y: 50 }, + { x: 17, y: 43 }, + { x: 18, y: 50 }, + { x: 19, y: 45 }, + { x: 20, y: 55 }, + { x: 21, y: 50 }, + { x: 22, y: 45 }, + { x: 23, y: 50 }, + { x: 24, y: 45 }, + { x: 25, y: 40 }, + { x: 26, y: 35 }, + { x: 27, y: 40 }, + { x: 28, y: 37 }, + { x: 29, y: 45 }, + { x: 30, y: 50 }, + { x: 31, y: 60 }, + { x: 32, y: 55 }, + { x: 33, y: 50 }, + { x: 34, y: 53 }, + { x: 35, y: 55 }, + { x: 36, y: 50 }, + { x: 37, y: 45 }, + { x: 38, y: 40 }, + { x: 39, y: 45 }, + { x: 40, y: 50 }, + { x: 41, y: 55 }, + { x: 42, y: 65 }, + { x: 43, y: 62 }, + { x: 44, y: 54 }, + { x: 45, y: 65 }, + { x: 46, y: 48 }, + { x: 47, y: 55 }, + { x: 48, y: 60 }, + { x: 49, y: 63 }, + { x: 50, y: 65 }, +]; + +function getChartData({ item, priceChange }: { item: Position; priceChange: number }) { + //the only way to create an immutable copy of array with objects inside. + const baseDashed = JSON.parse(JSON.stringify(baseData.slice(Math.floor(baseData.length) / 2))); + const baseSolid = JSON.parse(JSON.stringify(baseData.slice(0, Math.floor(baseData.length) / 2 + 1))); + + const leverage = item.leverage; + + baseDashed.forEach((item: { y: number; x: number }, index: number) => { + if (index !== 0) item.y += (item.y * priceChange) / 100; + }); + var leverageData = baseDashed.map((item: { x: number; y: number }, index: number) => { + if (index === 0) { + return { x: item.x, y: item.y }; + } + const gain = (priceChange * leverage) / 100; + return { x: item.x, y: item.y * (1 + gain) }; + }); + + return { + datasets: [ + { + backgroundColor: 'transparent', + borderColor: 'rgb(39, 107, 251)', + borderWidth: 4, + radius: 0, + data: baseSolid, + }, + { + backgroundColor: 'transparent', + borderColor: priceChange >= 0 ? 'rgb(51, 223, 204)' : 'rgb(255,79,79)', + borderWidth: 4, + radius: 0, + data: leverageData, + borderDash: [15, 3], + label: 'LEVERAGE', + }, + { + backgroundColor: 'transparent', + borderColor: 'rgb(86, 169, 255)', + borderWidth: 2, + radius: 0, + data: baseDashed, + borderDash: [8, 4], + label: 'HOLD', + }, + ], + }; +} + +function updateChartData({ + item, + priceChange, + chartRef, +}: { + item: Position; + priceChange: number; + chartRef: React.RefObject; +}) { + const data = getChartData({ item, priceChange }); + chartRef.current.chartInstance.data = data; + chartRef.current.chartInstance.canvas.parentNode.style.width = '100%'; + chartRef.current.chartInstance.canvas.parentNode.style.height = 'auto'; + chartRef.current.chartInstance.update(); +} + +function drawLabels(t: any, ctx: any, leverage: number, priceChange: number) { + ctx.save(); + ctx.font = 'normal normal bold 15px /1.5 Muli'; + ctx.textBaseline = 'bottom'; + + const chartInstance = t.chart; + const datasets = chartInstance.config.data.datasets; + datasets.forEach(function (ds: { label: any; borderColor: any }, index: number) { + const label = ds.label; + ctx.fillStyle = ds.borderColor; + + const meta = chartInstance.controller.getDatasetMeta(index); + const len = meta.data.length - 1; + const pointPostition = Math.floor(len / 2) - Math.floor(0.2 * len); + const x = meta.data[pointPostition]._model.x; + const xOffset = x; + const y = meta.data[pointPostition]._model.y; + let yOffset; + if (label === 'HOLD') { + yOffset = leverage * priceChange > 0 ? y * 1.2 : y * 0.8; + } else { + yOffset = leverage * priceChange > 0 ? y * 0.8 : y * 1.2; + } + + if (yOffset > chartInstance.canvas.parentNode.offsetHeight) { + // yOffset = 295; + chartInstance.canvas.parentNode.style.height = `${yOffset * 1.3}px`; + } + if (yOffset < 0) yOffset = 5; + if (label) ctx.fillText(label, xOffset, yOffset); + }); + ctx.restore(); +} + +export default function GainsChart({ item, priceChange }: { item: Position; priceChange: number }) { + const chartRef = useRef(); + useEffect(() => { + if (chartRef.current.chartInstance) updateChartData({ item, priceChange, chartRef }); + }, [priceChange, item.leverage]); + + return useMemo( + () => ( + { + const originalController = chartRef.current?.chartInstance?.controllers?.line; + if (originalController) + chartRef.current.chartInstance.controllers.line = chartRef.current.chartInstance.controllers.line.extend({ + draw: function () { + originalController.prototype.draw.call(this, arguments); + drawLabels(this, canvas.getContext('2d'), item.leverage, priceChange); + }, + }); + return getChartData({ item, priceChange }); + }} + options={{ + responsive: true, + maintainAspectRatio: true, + scaleShowLabels: false, + layout: { + padding: { + top: 30, + bottom: 80, + }, + }, + labels: { + render: 'title', + fontColor: ['green', 'white', 'red'], + precision: 2, + }, + animation: { + easing: 'easeOutExpo', + duration: 500, + }, + scales: { + xAxes: [ + { + display: false, + gridLines: { + display: false, + }, + type: 'linear', + position: 'bottom', + }, + ], + yAxes: [ + { + display: false, + gridLines: { + display: false, + }, + }, + ], + }, + legend: { + display: false, + }, + }} + /> + ), + [] + ); +} From 8a7b2b377c35493c2f710fb973b473b1650c6e4c Mon Sep 17 00:00:00 2001 From: "Mr. Dummy Tester" Date: Sat, 26 Dec 2020 19:58:23 -0600 Subject: [PATCH 11/13] feat: some fixes for the gains chart --- .../marginTrading/newPosition/GainsChart.tsx | 47 +++++++++++++++---- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/src/views/marginTrading/newPosition/GainsChart.tsx b/src/views/marginTrading/newPosition/GainsChart.tsx index f98f463..960b96f 100644 --- a/src/views/marginTrading/newPosition/GainsChart.tsx +++ b/src/views/marginTrading/newPosition/GainsChart.tsx @@ -126,6 +126,7 @@ function updateChartData({ } function drawLabels(t: any, ctx: any, leverage: number, priceChange: number) { + console.log('drawing'); ctx.save(); ctx.font = 'normal normal bold 15px /1.5 Muli'; ctx.textBaseline = 'bottom'; @@ -161,23 +162,33 @@ function drawLabels(t: any, ctx: any, leverage: number, priceChange: number) { export default function GainsChart({ item, priceChange }: { item: Position; priceChange: number }) { const chartRef = useRef(); + const [booted, setBooted] = useState(false); + const [canvas, setCanvas] = useState(); useEffect(() => { if (chartRef.current.chartInstance) updateChartData({ item, priceChange, chartRef }); }, [priceChange, item.leverage]); - return useMemo( + useEffect(() => { + if (chartRef.current && !booted && canvas) { + //@ts-ignore + const originalController = window.Chart.controllers.line; + //@ts-ignore + window.Chart.controllers.line = Chart.controllers.line.extend({ + draw: function () { + originalController.prototype.draw.call(this, arguments); + drawLabels(this, canvas.getContext('2d'), item.leverage, priceChange); + }, + }); + setBooted(true); + } + }, [chartRef, canvas]); + + const chart = useMemo( () => ( { - const originalController = chartRef.current?.chartInstance?.controllers?.line; - if (originalController) - chartRef.current.chartInstance.controllers.line = chartRef.current.chartInstance.controllers.line.extend({ - draw: function () { - originalController.prototype.draw.call(this, arguments); - drawLabels(this, canvas.getContext('2d'), item.leverage, priceChange); - }, - }); + setCanvas(canvas); return getChartData({ item, priceChange }); }} options={{ @@ -227,4 +238,22 @@ export default function GainsChart({ item, priceChange }: { item: Position; pric ), [] ); + + return ( +
+ {chart} +
+ past + today + future +
+
+ ); } From a0530957d5eeb27c995e9f02e37035047fd4d4e7 Mon Sep 17 00:00:00 2001 From: "Mr. Dummy Tester" Date: Sat, 26 Dec 2020 20:28:14 -0600 Subject: [PATCH 12/13] feat: Pause point for first part of form --- src/components/Input/numeric.tsx | 2 +- src/constants/labels.ts | 3 +- .../newPosition/NewPositionForm.tsx | 121 +++++++++--------- 3 files changed, 64 insertions(+), 62 deletions(-) diff --git a/src/components/Input/numeric.tsx b/src/components/Input/numeric.tsx index a270357..abfdbc5 100644 --- a/src/components/Input/numeric.tsx +++ b/src/components/Input/numeric.tsx @@ -21,7 +21,7 @@ export class NumericInput extends React.Component { if (value.startsWith && (value.startsWith('.') || value.startsWith('-.'))) { valueTemp = valueTemp.replace('.', '0.'); } - onChange?.(valueTemp.replace(/0*(\d+)/, '$1')); + if (valueTemp.replace) onChange?.(valueTemp.replace(/0*(\d+)/, '$1')); if (onBlur) { onBlur(); } diff --git a/src/constants/labels.ts b/src/constants/labels.ts index 8566999..8b9faff 100644 --- a/src/constants/labels.ts +++ b/src/constants/labels.ts @@ -61,7 +61,8 @@ export const LABELS = { TRADING_TABLE_TITLE_ACTIONS: 'Action', TRADING_ADD_POSITION: 'Add Position', MARGIN_TRADE_ACTION: 'Margin Trade', - MARGIN_TRADE_QUESTION: 'How much of this asset would you like?', + MARGIN_TRADE_CHOOSE_COLLATERAL_AND_LEVERAGE: 'Please choose your collateral and leverage.', + MARGIN_TRADE_QUESTION: 'Please choose how much of this asset you wish to purchase.', TABLE_TITLE_BUYING_POWER: 'Total Buying Power', NOT_ENOUGH_MARGIN_MESSAGE: 'Not enough buying power in oyster to make this trade at this leverage.', LEVERAGE_LIMIT_MESSAGE: diff --git a/src/views/marginTrading/newPosition/NewPositionForm.tsx b/src/views/marginTrading/newPosition/NewPositionForm.tsx index dc1a709..47a0f2b 100644 --- a/src/views/marginTrading/newPosition/NewPositionForm.tsx +++ b/src/views/marginTrading/newPosition/NewPositionForm.tsx @@ -42,72 +42,73 @@ export default function NewPositionForm({ lendingReserve, newPosition, setNewPos justifyContent: 'space-around', }} > -
{LABELS.SELECT_COLLATERAL}
- { - const id: string = cache.byParser(LendingReserveParser).find((acc) => acc === key) || ''; - const parser = cache.get(id) as ParsedAccount; - setNewPosition({ ...newPosition, collateral: parser }); - }} - /> +

{newPosition.error}

-
{LABELS.MARGIN_TRADE_QUESTION}
-
- - {LABELS.MARGIN_TRADE_CHOOSE_COLLATERAL_AND_LEVERAGE}

+
+ { + const id: string = cache.byParser(LendingReserveParser).find((acc) => acc === key) || ''; + const parser = cache.get(id) as ParsedAccount; + setNewPosition({ ...newPosition, collateral: parser }); }} - onChange={(v: string) => { - setNewPosition({ - ...newPosition, - asset: { ...newPosition.asset, value: v }, - }); - }} - placeholder='0.00' /> -
- { - tokens.find((t) => t.mintAddress === newPosition.asset.type?.info?.liquidityMint?.toBase58()) - ?.tokenSymbol - } +
+ { + setNewPosition({ ...newPosition, leverage: e.target.value }); + }} + > + 1x + 2x + 3x + 4x + 5x + + { + setNewPosition({ ...newPosition, leverage }); + }} + />
+
+

{LABELS.MARGIN_TRADE_QUESTION}

+ +
+
+ + { + setNewPosition({ + ...newPosition, + asset: { ...newPosition.asset, value: v }, + }); + }} + placeholder='0.00' + /> +
+ { + tokens.find((t) => t.mintAddress === newPosition.asset.type?.info?.liquidityMint?.toBase58()) + ?.tokenSymbol + } +
+
-
- { - setNewPosition({ ...newPosition, leverage: e.target.value }); - }} - > - 1x - 2x - 3x - 4x - 5x - - { - setNewPosition({ ...newPosition, leverage }); - }} - /> -

{newPosition.error}

-
-
From b1dfe1676caee4e11cbcb5d3584fd8155d9d480a Mon Sep 17 00:00:00 2001 From: "Mr. Dummy Tester" Date: Mon, 28 Dec 2020 17:36:45 -0600 Subject: [PATCH 13/13] feat: THink we're getting very close to being done with this form. --- package.json | 1 + src/components/CollateralInput/index.tsx | 154 ++++++++++++++++ src/components/CollateralInput/style.less | 62 +++++++ src/components/PoolPrice/index.tsx | 56 ++++++ src/components/SupplyOverview/index.tsx | 100 ++++++++++ src/components/TokenDisplay/index.tsx | 47 +++++ src/constants/labels.ts | 5 +- src/contexts/market.tsx | 1 + src/models/lending/reserve.ts | 2 - src/utils/utils.ts | 6 + .../marginTrading/newPosition/Breakdown.tsx | 118 +++++++----- .../marginTrading/newPosition/GainsChart.tsx | 5 +- .../newPosition/NewPositionForm.tsx | 172 +++++++++++------- .../marginTrading/newPosition/PoolHealth.tsx | 21 +++ src/views/marginTrading/newPosition/index.tsx | 21 +-- .../marginTrading/newPosition/interfaces.tsx | 7 +- .../marginTrading/newPosition/leverage.ts | 53 +++--- .../marginTrading/newPosition/style.less | 18 +- src/views/marginTrading/newPosition/utils.ts | 48 +++++ 19 files changed, 736 insertions(+), 161 deletions(-) create mode 100644 src/components/CollateralInput/index.tsx create mode 100644 src/components/CollateralInput/style.less create mode 100644 src/components/PoolPrice/index.tsx create mode 100644 src/components/SupplyOverview/index.tsx create mode 100644 src/components/TokenDisplay/index.tsx create mode 100644 src/views/marginTrading/newPosition/PoolHealth.tsx create mode 100644 src/views/marginTrading/newPosition/utils.ts diff --git a/package.json b/package.json index 53be587..f0d9200 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "eventemitter3": "^4.0.7", "identicon.js": "^2.3.3", "jazzicon": "^1.5.0", + "lodash": "^4.17.20", "react": "^16.13.1", "react-chartjs-2": "^2.11.1", "react-dom": "^16.13.1", diff --git a/src/components/CollateralInput/index.tsx b/src/components/CollateralInput/index.tsx new file mode 100644 index 0000000..ba116e6 --- /dev/null +++ b/src/components/CollateralInput/index.tsx @@ -0,0 +1,154 @@ +import React, { useEffect, useState } from 'react'; +import { cache, ParsedAccount } from '../../contexts/accounts'; +import { useConnectionConfig } from '../../contexts/connection'; +import { useLendingReserves, useUserDeposits } from '../../hooks'; +import { LendingReserve, LendingMarket, LendingReserveParser } from '../../models'; +import { getTokenName } from '../../utils/utils'; +import { Card, Select } from 'antd'; +import { TokenIcon } from '../TokenIcon'; +import { NumericInput } from '../Input/numeric'; +import './style.less'; +import { TokenDisplay } from '../TokenDisplay'; + +const { Option } = Select; + +// User can choose a collateral they want to use, and then this will display the balance they have in Oyster's lending +// reserve for that collateral type. +export default function CollateralInput(props: { + title: string; + amount?: number | null; + reserve: LendingReserve; + disabled?: boolean; + onCollateralReserve?: (id: string) => void; + onLeverage?: (leverage: number) => void; + onInputChange: (value: number | null) => void; + hideBalance?: boolean; + showLeverageSelector?: boolean; + leverage?: number; +}) { + const { reserveAccounts } = useLendingReserves(); + const { tokenMap } = useConnectionConfig(); + const [collateralReserve, setCollateralReserve] = useState(); + const [balance, setBalance] = useState(0); + const [lastAmount, setLastAmount] = useState(''); + const userDeposits = useUserDeposits(); + + useEffect(() => { + const id: string = cache.byParser(LendingReserveParser).find((acc) => acc === collateralReserve) || ''; + const parser = cache.get(id) as ParsedAccount; + if (parser) { + const collateralDeposit = userDeposits.userDeposits.find( + (u) => u.reserve.info.liquidityMint.toBase58() === parser.info.liquidityMint.toBase58() + ); + if (collateralDeposit) setBalance(collateralDeposit.info.amount); + else setBalance(0); + } + }, [collateralReserve, userDeposits]); + + const market = cache.get(props.reserve.lendingMarket) as ParsedAccount; + if (!market) return null; + + const onlyQuoteAllowed = !props.reserve?.liquidityMint?.equals(market?.info?.quoteMint); + + const renderReserveAccounts = reserveAccounts + .filter((reserve) => reserve.info !== props.reserve) + .filter((reserve) => !onlyQuoteAllowed || reserve.info.liquidityMint.equals(market.info.quoteMint)) + .map((reserve) => { + const mint = reserve.info.liquidityMint.toBase58(); + const address = reserve.pubkey.toBase58(); + const name = getTokenName(tokenMap, mint); + return ( + + ); + }); + + return ( + +
+
{props.title}
+ + {!props.hideBalance && ( +
props.onInputChange && props.onInputChange(balance)}> + Balance: {balance.toFixed(6)} +
+ )} +
+
+ { + if (props.onInputChange && parseFloat(val) != props.amount) { + if (!val || !parseFloat(val)) props.onInputChange(null); + else props.onInputChange(parseFloat(val)); + } + setLastAmount(val); + }} + style={{ + fontSize: 20, + boxShadow: 'none', + borderColor: 'transparent', + outline: 'transparent', + }} + placeholder='0.00' + /> +
+ {props.showLeverageSelector && ( + + )} + {!props.disabled ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/src/components/CollateralInput/style.less b/src/components/CollateralInput/style.less new file mode 100644 index 0000000..8d01376 --- /dev/null +++ b/src/components/CollateralInput/style.less @@ -0,0 +1,62 @@ +.ccy-input { + margin-top: 10px; + margin-bottom: 10px; + + .ant-select-selector, + .ant-select-selector:focus, + .ant-select-selector:active { + border-color: transparent !important; + box-shadow: none !important; + } + .ant-select-selection-item { + display: flex; + + .token-balance { + display: none; + } + } +} + +.token-balance { + color: grey; +} + +.ccy-input-header { + display: grid; + + grid-template-columns: repeat(2, 1fr); + grid-column-gap: 10px; + + -webkit-box-pack: justify; + justify-content: space-between; + -webkit-box-align: center; + align-items: center; + flex-direction: row; + padding: 10px 20px 0px 20px; +} + +.ccy-input-header-left { + width: 100%; + box-sizing: border-box; + margin: 0px; + min-width: 0px; + display: flex; + padding: 0px; + -webkit-box-align: center; + align-items: center; + width: fit-content; +} + +.ccy-input-header-right { + width: 100%; + display: flex; + flex-direction: row; + -webkit-box-align: center; + align-items: center; + justify-self: flex-end; + justify-content: flex-end; +} + +.ant-select-dropdown { + width: 150px !important; +} diff --git a/src/components/PoolPrice/index.tsx b/src/components/PoolPrice/index.tsx new file mode 100644 index 0000000..9dada34 --- /dev/null +++ b/src/components/PoolPrice/index.tsx @@ -0,0 +1,56 @@ +import { Card, Row, Col } from 'antd'; +import React, { useMemo } from 'react'; +import { useMint } from '../../contexts/accounts'; +import { useEnrichedPools } from '../../contexts/market'; +import { useUserAccounts } from '../../hooks'; +import { PoolInfo } from '../../models'; +import { formatPriceNumber } from '../../utils/utils'; + +export const PoolPrice = (props: { pool: PoolInfo }) => { + const pool = props.pool; + const pools = useMemo(() => [props.pool].filter((p) => p) as PoolInfo[], [props.pool]); + const enriched = useEnrichedPools(pools)[0]; + + const { userAccounts } = useUserAccounts(); + const lpMint = useMint(pool.pubkeys.mint); + + const ratio = + userAccounts + .filter((f) => pool.pubkeys.mint.equals(f.info.mint)) + .reduce((acc, item) => item.info.amount.toNumber() + acc, 0) / (lpMint?.supply.toNumber() || 0); + + if (!enriched) { + return null; + } + return ( + + + + {formatPriceNumber.format(parseFloat(enriched.liquidityA) / parseFloat(enriched.liquidityB))} + + + {formatPriceNumber.format(parseFloat(enriched.liquidityB) / parseFloat(enriched.liquidityA))} + + + {ratio * 100 < 0.001 && ratio > 0 ? '<' : ''} +  {formatPriceNumber.format(ratio * 100)}% + + + + + {enriched.names[0]} per {enriched.names[1]} + + + {enriched.names[1]} per {enriched.names[0]} + + Share of pool + + + ); +}; diff --git a/src/components/SupplyOverview/index.tsx b/src/components/SupplyOverview/index.tsx new file mode 100644 index 0000000..5978e9e --- /dev/null +++ b/src/components/SupplyOverview/index.tsx @@ -0,0 +1,100 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import { PoolInfo } from '../../models'; +import echarts from 'echarts'; +import { formatNumber, formatUSD } from '../../utils/utils'; +import { useEnrichedPools } from '../../contexts/market'; + +export const SupplyOverview = (props: { pool?: PoolInfo }) => { + const { pool } = props; + const pools = useMemo(() => (pool ? [pool] : []), [pool]); + const enriched = useEnrichedPools(pools); + const chartDiv = useRef(null); + + // dispose chart + useEffect(() => { + const div = chartDiv.current; + return () => { + let instance = div && echarts.getInstanceByDom(div); + instance && instance.dispose(); + }; + }, []); + + useEffect(() => { + if (!chartDiv.current || enriched.length === 0) { + return; + } + + let instance = echarts.getInstanceByDom(chartDiv.current); + if (!instance) { + instance = echarts.init(chartDiv.current as any); + } + + const data = [ + { + name: enriched[0].names[0], + value: enriched[0].liquidityAinUsd, + tokens: enriched[0].liquidityA, + }, + { + name: enriched[0].names[1], + value: enriched[0].liquidityBinUsd, + tokens: enriched[0].liquidityB, + }, + ]; + + instance.setOption({ + tooltip: { + trigger: 'item', + formatter: function (params: any) { + var val = formatUSD.format(params.value); + var tokenAmount = formatNumber.format(params.data.tokens); + return `${params.name}: \n${val}\n(${tokenAmount})`; + }, + }, + series: [ + { + name: 'Liquidity', + type: 'pie', + top: 0, + bottom: 0, + left: 0, + right: 0, + animation: false, + label: { + fontSize: 14, + show: true, + formatter: function (params: any) { + var val = formatUSD.format(params.value); + var tokenAmount = formatNumber.format(params.data.tokens); + return `{c|${params.name}}\n{r|${tokenAmount}}\n{r|${val}}`; + }, + rich: { + c: { + color: 'black', + lineHeight: 22, + align: 'center', + }, + r: { + color: 'black', + align: 'right', + }, + }, + color: 'rgba(255, 255, 255, 0.5)', + }, + itemStyle: { + normal: { + borderColor: '#000', + }, + }, + data, + }, + ], + }); + }, [enriched]); + + if (enriched.length === 0) { + return null; + } + + return
; +}; diff --git a/src/components/TokenDisplay/index.tsx b/src/components/TokenDisplay/index.tsx new file mode 100644 index 0000000..4632711 --- /dev/null +++ b/src/components/TokenDisplay/index.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { useMint, useAccountByMint } from '../../contexts/accounts'; +import { TokenIcon } from '../TokenIcon'; + +export const TokenDisplay = (props: { + name: string; + mintAddress: string; + icon?: JSX.Element; + showBalance?: boolean; +}) => { + const { showBalance, mintAddress, name, icon } = props; + const tokenMint = useMint(mintAddress); + const tokenAccount = useAccountByMint(mintAddress); + + let balance: number = 0; + let hasBalance: boolean = false; + if (showBalance) { + if (tokenAccount && tokenMint) { + balance = tokenAccount.info.amount.toNumber() / Math.pow(10, tokenMint.decimals); + hasBalance = balance > 0; + } + } + + return ( + <> +
+
+ {icon || } + {name} +
+ {showBalance ? ( + +   {hasBalance ? (balance < 0.001 ? '<0.001' : balance.toFixed(3)) : '-'} + + ) : null} +
+ + ); +}; diff --git a/src/constants/labels.ts b/src/constants/labels.ts index 8b9faff..7e28426 100644 --- a/src/constants/labels.ts +++ b/src/constants/labels.ts @@ -65,7 +65,8 @@ export const LABELS = { MARGIN_TRADE_QUESTION: 'Please choose how much of this asset you wish to purchase.', TABLE_TITLE_BUYING_POWER: 'Total Buying Power', NOT_ENOUGH_MARGIN_MESSAGE: 'Not enough buying power in oyster to make this trade at this leverage.', - LEVERAGE_LIMIT_MESSAGE: - 'With liquidity pools in their current state, you are not allowed to use leverage at this multiple. You will need more margin to make this trade.', + SET_MORE_MARGIN_MESSAGE: 'You need more margin to match this leverage amount to make this trade.', + LEVERAGE_LIMIT_MESSAGE: 'You will need more margin to make this trade.', NO_DEPOSIT_MESSAGE: 'You need to deposit coin of this type into oyster before trading with it on margin.', + NO_COLL_TYPE_MESSAGE: 'Choose Collateral CCY', }; diff --git a/src/contexts/market.tsx b/src/contexts/market.tsx index aefcb2a..df8ece7 100644 --- a/src/contexts/market.tsx +++ b/src/contexts/market.tsx @@ -272,6 +272,7 @@ export const useEnrichedPools = (pools: PoolInfo[]) => { dispose && dispose(); subscriptions.forEach((dispose) => dispose && dispose()); }; + // Do not add pools here, causes a really bad infinite rendering loop. Use poolKeys instead. }, [tokenMap, dailyVolume, poolKeys, subscribeToMarket, marketEmitter, marketsByMint]); return enriched; diff --git a/src/models/lending/reserve.ts b/src/models/lending/reserve.ts index 05035b0..9e1c83f 100644 --- a/src/models/lending/reserve.ts +++ b/src/models/lending/reserve.ts @@ -178,12 +178,10 @@ export const collateralExchangeRate = (reserve?: LendingReserve) => { export const collateralToLiquidity = (collateralAmount: BN | number, reserve?: LendingReserve) => { const amount = typeof collateralAmount === 'number' ? collateralAmount : collateralAmount.toNumber(); - console.log('Exchange rate:', collateralExchangeRate(reserve)); return Math.floor(amount / collateralExchangeRate(reserve)); }; export const liquidityToCollateral = (liquidityAmount: BN | number, reserve?: LendingReserve) => { const amount = typeof liquidityAmount === 'number' ? liquidityAmount : liquidityAmount.toNumber(); - console.log('Exchange rate:', collateralExchangeRate(reserve)); return Math.floor(amount * collateralExchangeRate(reserve)); }; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 43ec1e4..73c5907 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -15,6 +15,12 @@ export interface KnownToken { export type KnownTokenMap = Map; +export const formatPriceNumber = new Intl.NumberFormat('en-US', { + style: 'decimal', + minimumFractionDigits: 2, + maximumFractionDigits: 8, +}); + export function useLocalStorageState(key: string, defaultState?: string) { const [state, setState] = useState(() => { // NOTE: Not sure if this is ok diff --git a/src/views/marginTrading/newPosition/Breakdown.tsx b/src/views/marginTrading/newPosition/Breakdown.tsx index c3a8b30..46155d8 100644 --- a/src/views/marginTrading/newPosition/Breakdown.tsx +++ b/src/views/marginTrading/newPosition/Breakdown.tsx @@ -4,17 +4,23 @@ import { Position } from './interfaces'; import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'; import tokens from '../../../config/tokens.json'; import GainsChart from './GainsChart'; +import { usePoolAndTradeInfoFrom } from './utils'; -export function Breakdown({ item }: { item: Position }) { - let myPart = parseFloat(item.asset?.value || '0') / item.leverage; - const brokeragePart = parseFloat(item.asset?.value || '0') - myPart; +export default function Breakdown({ item }: { item: Position }) { + const { enrichedPools, leverage } = usePoolAndTradeInfoFrom(item); + + const exchangeRate = enrichedPools.length == 0 ? 1 : enrichedPools[0].liquidityB / enrichedPools[0].liquidityA; + + let myPart = item.collateral.value || 0; + const brokeragePart = (item.collateral.value || 0) * leverage - myPart; const brokerageColor = 'brown'; const myColor = 'blue'; const gains = 'green'; const losses = 'red'; const token = tokens.find((t) => t.mintAddress === item.asset.type?.info?.liquidityMint?.toBase58()); + const collateralToken = tokens.find((t) => t.mintAddress === item.collateral.type?.info?.liquidityMint?.toBase58()); - const [myGain, setMyGain] = useState(0); + const [myGain, setMyGain] = useState(10); const profitPart = (myPart + brokeragePart) * (myGain / 100); let progressBar = null; if (profitPart > 0) { @@ -46,52 +52,64 @@ export function Breakdown({ item }: { item: Position }) { } return ( -
- { - setMyGain(v); - }} - style={{ marginBottom: '20px' }} - /> -
- - - - - - - - 0 ? gains : losses }} - suffix={token?.tokenSymbol} - prefix={profitPart > 0 ? : } - /> - -
- - {progressBar} +
+ +
+ + + + + + + + 0 ? gains : losses }} + suffix={token?.tokenSymbol} + prefix={profitPart > 0 ? : } + /> + +
+
+ {progressBar} +
+ + + {p}%} + max={100} + min={-100} + tooltipPlacement={'top'} + onChange={(v: number) => { + setMyGain(v); + }} + style={{ marginBottom: '20px' }} + /> + + + credit + + +
); } diff --git a/src/views/marginTrading/newPosition/GainsChart.tsx b/src/views/marginTrading/newPosition/GainsChart.tsx index 960b96f..7fa2969 100644 --- a/src/views/marginTrading/newPosition/GainsChart.tsx +++ b/src/views/marginTrading/newPosition/GainsChart.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Line } from 'react-chartjs-2'; import { Position } from './interfaces'; +import { debounce } from 'lodash'; // Special thanks to // https://github.com/bZxNetwork/fulcrum_ui/blob/development/packages/fulcrum-website/assets/js/trading.js @@ -126,7 +127,6 @@ function updateChartData({ } function drawLabels(t: any, ctx: any, leverage: number, priceChange: number) { - console.log('drawing'); ctx.save(); ctx.font = 'normal normal bold 15px /1.5 Muli'; ctx.textBaseline = 'bottom'; @@ -159,13 +159,14 @@ function drawLabels(t: any, ctx: any, leverage: number, priceChange: number) { }); ctx.restore(); } +const debouncedUpdateChartData = debounce(updateChartData, 200); export default function GainsChart({ item, priceChange }: { item: Position; priceChange: number }) { const chartRef = useRef(); const [booted, setBooted] = useState(false); const [canvas, setCanvas] = useState(); useEffect(() => { - if (chartRef.current.chartInstance) updateChartData({ item, priceChange, chartRef }); + if (chartRef.current.chartInstance) debouncedUpdateChartData({ item, priceChange, chartRef }); }, [priceChange, item.leverage]); useEffect(() => { diff --git a/src/views/marginTrading/newPosition/NewPositionForm.tsx b/src/views/marginTrading/newPosition/NewPositionForm.tsx index 47a0f2b..7dd383f 100644 --- a/src/views/marginTrading/newPosition/NewPositionForm.tsx +++ b/src/views/marginTrading/newPosition/NewPositionForm.tsx @@ -1,16 +1,16 @@ -import { Button, Card, Radio } from 'antd'; +import { Button, Card } from 'antd'; import React, { useState } from 'react'; import { ActionConfirmation } from '../../../components/ActionConfirmation'; -import { NumericInput } from '../../../components/Input/numeric'; -import { TokenIcon } from '../../../components/TokenIcon'; import { LABELS } from '../../../constants'; import { cache, ParsedAccount } from '../../../contexts/accounts'; import { LendingReserve, LendingReserveParser } from '../../../models/lending/reserve'; import { Position } from './interfaces'; -import tokens from '../../../config/tokens.json'; -import { CollateralSelector } from '../../../components/CollateralSelector'; -import { Breakdown } from './Breakdown'; import { useLeverage } from './leverage'; +import CollateralInput from '../../../components/CollateralInput'; +import { usePoolAndTradeInfoFrom } from './utils'; +import { UserDeposit } from '../../../hooks'; +import { ArrowDownOutlined } from '@ant-design/icons'; +import { useWallet } from '../../../contexts/wallet'; interface NewPositionFormProps { lendingReserve: ParsedAccount; @@ -18,6 +18,49 @@ interface NewPositionFormProps { setNewPosition: (pos: Position) => void; } +export const generateActionLabel = (connected: boolean, newPosition: Position) => { + return !connected ? LABELS.CONNECT_LABEL : newPosition.error ? newPosition.error : LABELS.TRADING_ADD_POSITION; +}; + +function onUserChangesLeverageOrCollateralValue({ + newPosition, + setNewPosition, + collateralDeposit, + enrichedPools, +}: { + newPosition: Position; + setNewPosition: (pos: Position) => void; + enrichedPools: any[]; + collateralDeposit: UserDeposit | undefined; +}) { + setNewPosition(newPosition); // It has always changed, need to guarantee save + // if user changes leverage, we need to adjust the amount they desire up. + if (collateralDeposit && enrichedPools.length) { + const exchangeRate = enrichedPools[0].liquidityB / enrichedPools[0].liquidityA; + const convertedAmount = (newPosition.collateral.value || 0) * newPosition.leverage * exchangeRate; + setNewPosition({ ...newPosition, asset: { ...newPosition.asset, value: convertedAmount } }); + } +} + +function onUserChangesAssetValue({ + newPosition, + setNewPosition, + collateralDeposit, + enrichedPools, +}: { + newPosition: Position; + setNewPosition: (pos: Position) => void; + enrichedPools: any[]; + collateralDeposit: UserDeposit | undefined; +}) { + setNewPosition(newPosition); // It has always changed, need to guarantee save + if (collateralDeposit && enrichedPools.length) { + const exchangeRate = enrichedPools[0].liquidityB / enrichedPools[0].liquidityA; + const convertedAmount = (newPosition.asset.value || 0) / (exchangeRate * newPosition.leverage); + setNewPosition({ ...newPosition, collateral: { ...newPosition.collateral, value: convertedAmount } }); + } +} + export default function NewPositionForm({ lendingReserve, newPosition, setNewPosition }: NewPositionFormProps) { const bodyStyle: React.CSSProperties = { display: 'flex', @@ -27,11 +70,12 @@ export default function NewPositionForm({ lendingReserve, newPosition, setNewPos height: '100%', }; const [showConfirmation, setShowConfirmation] = useState(false); - + const { enrichedPools, collateralDeposit } = usePoolAndTradeInfoFrom(newPosition); useLeverage({ newPosition, setNewPosition }); + const { wallet, connected } = useWallet(); return ( - + {showConfirmation ? ( setShowConfirmation(false)} /> ) : ( @@ -42,78 +86,76 @@ export default function NewPositionForm({ lendingReserve, newPosition, setNewPos justifyContent: 'space-around', }} > -

{newPosition.error}

- -

{LABELS.MARGIN_TRADE_CHOOSE_COLLATERAL_AND_LEVERAGE}

- { + const newPos = { ...newPosition, collateral: { ...newPosition.collateral, value: val } }; + onUserChangesLeverageOrCollateralValue({ + newPosition: newPos, + setNewPosition, + enrichedPools, + collateralDeposit, + }); + }} onCollateralReserve={(key) => { const id: string = cache.byParser(LendingReserveParser).find((acc) => acc === key) || ''; const parser = cache.get(id) as ParsedAccount; - setNewPosition({ ...newPosition, collateral: parser }); + const newPos = { ...newPosition, collateral: { value: newPosition.collateral.value, type: parser } }; + onUserChangesLeverageOrCollateralValue({ + newPosition: newPos, + setNewPosition, + enrichedPools, + collateralDeposit, + }); }} + showLeverageSelector={true} + onLeverage={(leverage: number) => { + const newPos = { ...newPosition, leverage }; + onUserChangesLeverageOrCollateralValue({ + newPosition: newPos, + setNewPosition, + enrichedPools, + collateralDeposit, + }); + }} + leverage={newPosition.leverage} /> -
- { - setNewPosition({ ...newPosition, leverage: e.target.value }); - }} - > - 1x - 2x - 3x - 4x - 5x - - { - setNewPosition({ ...newPosition, leverage }); - }} - /> -
-
-

{LABELS.MARGIN_TRADE_QUESTION}

+
-
- - { - setNewPosition({ - ...newPosition, - asset: { ...newPosition.asset, value: v }, + {newPosition.asset.type && ( + { + const newPos = { ...newPosition, asset: { ...newPosition.asset, value: val } }; + onUserChangesAssetValue({ + newPosition: newPos, + setNewPosition, + enrichedPools, + collateralDeposit, }); }} - placeholder='0.00' + disabled + hideBalance={true} /> -
- { - tokens.find((t) => t.mintAddress === newPosition.asset.type?.info?.liquidityMint?.toBase58()) - ?.tokenSymbol - } -
-
- -
-
)} diff --git a/src/views/marginTrading/newPosition/PoolHealth.tsx b/src/views/marginTrading/newPosition/PoolHealth.tsx new file mode 100644 index 0000000..87884e7 --- /dev/null +++ b/src/views/marginTrading/newPosition/PoolHealth.tsx @@ -0,0 +1,21 @@ +import Card from 'antd/lib/card'; +import React from 'react'; +import { PoolPrice } from '../../../components/PoolPrice'; +import { SupplyOverview } from '../../../components/SupplyOverview'; +import { Position } from './interfaces'; +import { usePoolAndTradeInfoFrom } from './utils'; + +export default function PoolHealth({ newPosition }: { newPosition: Position }) { + const { pool } = usePoolAndTradeInfoFrom(newPosition); + return ( + + {!pool && Choose a CCY to see exchange rate information.} + {pool && ( + <> + + + + )} + + ); +} diff --git a/src/views/marginTrading/newPosition/index.tsx b/src/views/marginTrading/newPosition/index.tsx index 5240033..00aa0b3 100644 --- a/src/views/marginTrading/newPosition/index.tsx +++ b/src/views/marginTrading/newPosition/index.tsx @@ -1,13 +1,12 @@ import React, { useState } from 'react'; -import { useLendingReserve, useTokenName } from '../../../hooks'; +import { useLendingReserve } from '../../../hooks'; import { useParams } from 'react-router-dom'; import './style.less'; -import tokens from '../../../config/tokens.json'; -import { SideReserveOverview, SideReserveOverviewMode } from '../../../components/SideReserveOverview'; import NewPositionForm from './NewPositionForm'; import { Position } from './interfaces'; -import { useEffect } from 'react'; +import Breakdown from './Breakdown'; +import PoolHealth from './PoolHealth'; export const NewPosition = () => { const { id } = useParams<{ id: string }>(); @@ -15,7 +14,8 @@ export const NewPosition = () => { const [newPosition, setNewPosition] = useState({ id: null, leverage: 1, - asset: { value: '0' }, + collateral: {}, + asset: {}, }); if (!lendingReserve) { @@ -29,12 +29,11 @@ export const NewPosition = () => { return (
- - +
+ + +
+
); diff --git a/src/views/marginTrading/newPosition/interfaces.tsx b/src/views/marginTrading/newPosition/interfaces.tsx index fe6ea0b..bbaf490 100644 --- a/src/views/marginTrading/newPosition/interfaces.tsx +++ b/src/views/marginTrading/newPosition/interfaces.tsx @@ -10,10 +10,13 @@ export interface Token { export interface Position { id?: number | null; leverage: number; - collateral?: ParsedAccount; + collateral: { + type?: ParsedAccount; + value?: number | null; + }; asset: { type?: ParsedAccount; - value: string; // because NumericInput returns strings and I dont want to deal with fixing it right now + value?: number | null; }; error?: string; } diff --git a/src/views/marginTrading/newPosition/leverage.ts b/src/views/marginTrading/newPosition/leverage.ts index de3eb68..4e1e4f3 100644 --- a/src/views/marginTrading/newPosition/leverage.ts +++ b/src/views/marginTrading/newPosition/leverage.ts @@ -1,9 +1,7 @@ import { useEffect } from 'react'; import { LABELS } from '../../../constants'; -import { useEnrichedPools } from '../../../contexts/market'; -import { useUserDeposits } from '../../../hooks'; -import { usePoolForBasket } from '../../../utils/pools'; import { Position } from './interfaces'; +import { usePoolAndTradeInfoFrom } from './utils'; export function useLeverage({ newPosition, @@ -12,52 +10,57 @@ export function useLeverage({ newPosition: Position; setNewPosition: (pos: Position) => void; }) { - const collType = newPosition.collateral; - const desiredType = newPosition.asset.type; - - const pool = usePoolForBasket([ - collType?.info?.liquidityMint?.toBase58(), - desiredType?.info?.liquidityMint?.toBase58(), - ]); - - const userDeposits = useUserDeposits(); - const collateralDeposit = userDeposits.userDeposits.find( - (u) => u.reserve.info.liquidityMint.toBase58() == collType?.info?.liquidityMint?.toBase58() - ); - const enriched = useEnrichedPools(pool ? [pool] : []); + const { + enrichedPools, + collateralDeposit, + collType, + desiredType, + collValue, + desiredValue, + leverage, + } = usePoolAndTradeInfoFrom(newPosition); // Leverage validation - if you choose this leverage, is it allowable, with your buying power and with // the pool we have to cover you? useEffect(() => { + if (!collType) { + setNewPosition({ ...newPosition, error: LABELS.NO_COLL_TYPE_MESSAGE }); + return; + } + if (!collateralDeposit) { setNewPosition({ ...newPosition, error: LABELS.NO_DEPOSIT_MESSAGE }); return; } - if (!collType || !desiredType || !newPosition.asset.value || !enriched || enriched.length == 0) { + if (!desiredType || !newPosition.asset.value || !enrichedPools || enrichedPools.length == 0) { return; } // If there is more of A than B - const exchangeRate = enriched[0].liquidityB / enriched[0].liquidityA; - const amountDesiredToPurchase = parseFloat(newPosition.asset.value); + const exchangeRate = enrichedPools[0].liquidityB / enrichedPools[0].liquidityA; const leverageDesired = newPosition.leverage; const amountAvailableInOysterForMargin = collateralDeposit.info.amount * exchangeRate; - const amountToDepositOnMargin = amountDesiredToPurchase / leverageDesired; + const amountToDepositOnMargin = desiredValue / leverageDesired; if (amountToDepositOnMargin > amountAvailableInOysterForMargin) { setNewPosition({ ...newPosition, error: LABELS.NOT_ENOUGH_MARGIN_MESSAGE }); return; } - const liqA = enriched[0].liquidityA; - const liqB = enriched[0].liquidityB; + if (amountToDepositOnMargin > collValue) { + setNewPosition({ ...newPosition, error: LABELS.SET_MORE_MARGIN_MESSAGE }); + return; + } + + const liqA = enrichedPools[0].liquidityA; + const liqB = enrichedPools[0].liquidityB; const supplyRatio = liqA / liqB; // change in liquidity is amount desired (in units of B) converted to collateral units(A) - const chgLiqA = amountDesiredToPurchase / exchangeRate; + const chgLiqA = desiredValue / exchangeRate; const newLiqA = liqA - chgLiqA; - const newLiqB = liqB + amountDesiredToPurchase; + const newLiqB = liqB + desiredValue; const newSupplyRatio = newLiqA / newLiqB; const priceImpact = Math.abs(100 - 100 * (newSupplyRatio / supplyRatio)); @@ -69,5 +72,5 @@ export function useLeverage({ return; } setNewPosition({ ...newPosition, error: '' }); - }, [collType, desiredType, newPosition.asset.value, newPosition.leverage, enriched]); + }, [collType, desiredType, desiredValue, leverage, enrichedPools, collValue, collateralDeposit?.info.amount]); } diff --git a/src/views/marginTrading/newPosition/style.less b/src/views/marginTrading/newPosition/style.less index 3aff82b..53e1651 100644 --- a/src/views/marginTrading/newPosition/style.less +++ b/src/views/marginTrading/newPosition/style.less @@ -16,11 +16,25 @@ } .new-position-item-left { - flex: 60%; + flex: 1; + flex-direction: column; } +.new-position-item-top-left { + flex: 1; +} +.new-position-item-bottom-left { + flex: 1; +} +.new-position-item-top-right { + flex: 1; +} +.new-position-item-bottom-right { + flex: 1; +} .new-position-item-right { - flex: 30%; + flex: 1; + flex-direction: column; } /* Responsive layout - makes a one column layout instead of a two-column layout */ diff --git a/src/views/marginTrading/newPosition/utils.ts b/src/views/marginTrading/newPosition/utils.ts new file mode 100644 index 0000000..7b9a7ca --- /dev/null +++ b/src/views/marginTrading/newPosition/utils.ts @@ -0,0 +1,48 @@ +import { useEffect } from 'react'; +import { ParsedAccount } from '../../../contexts/accounts'; +import { useEnrichedPools } from '../../../contexts/market'; +import { UserDeposit, useUserDeposits } from '../../../hooks'; +import { LendingReserve, PoolInfo } from '../../../models'; +import { usePoolForBasket } from '../../../utils/pools'; +import { Position } from './interfaces'; + +export function usePoolAndTradeInfoFrom( + newPosition: Position +): { + enrichedPools: any[]; + collateralDeposit: UserDeposit | undefined; + collType: ParsedAccount | undefined; + desiredType: ParsedAccount | undefined; + collValue: number; + desiredValue: number; + leverage: number; + pool: PoolInfo | undefined; +} { + const collType = newPosition.collateral.type; + const desiredType = newPosition.asset.type; + const collValue = newPosition.collateral.value || 0; + const desiredValue = newPosition.asset.value || 0; + + const pool = usePoolForBasket([ + collType?.info?.liquidityMint?.toBase58(), + desiredType?.info?.liquidityMint?.toBase58(), + ]); + + const userDeposits = useUserDeposits(); + const collateralDeposit = userDeposits.userDeposits.find( + (u) => u.reserve.info.liquidityMint.toBase58() == collType?.info?.liquidityMint?.toBase58() + ); + + const enrichedPools = useEnrichedPools(pool ? [pool] : []); + + return { + enrichedPools, + collateralDeposit, + collType, + desiredType, + collValue, + desiredValue, + leverage: newPosition.leverage, + pool, + }; +}