diff --git a/packages/bridge/src/components/Input/ethereum.tsx b/packages/bridge/src/components/Input/ethereum.tsx new file mode 100644 index 0000000..fb0277e --- /dev/null +++ b/packages/bridge/src/components/Input/ethereum.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useState } from 'react'; +import { contexts, utils, ParsedAccount, NumericInput, TokenIcon, TokenDisplay } from '@oyster/common'; +import { Card, Select } from 'antd'; +import './style.less'; +const { getTokenName } = utils; +const { cache } = contexts.Accounts; +const { useConnectionConfig } = contexts.Connection; + +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 function EthereumInput(props: { + title: string; + amount?: number | null; + disabled?: boolean; + onInputChange: (value: number | null) => void; + hideBalance?: boolean; + useWalletBalance?: boolean; + useFirstReserve?: boolean; + showLeverageSelector?: boolean; + leverage?: number; +}) { + const { tokenMap } = useConnectionConfig(); + const [acco, setCollateralReserve] = useState(); + const [balance, setBalance] = useState(0); + const [lastAmount, setLastAmount] = useState(''); + + + const renderReserveAccounts = [].map((reserve: any) => { + 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.disabled ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/packages/bridge/src/components/Input/index.tsx b/packages/bridge/src/components/Input/index.tsx new file mode 100644 index 0000000..ffc769d --- /dev/null +++ b/packages/bridge/src/components/Input/index.tsx @@ -0,0 +1,2 @@ +export * from './solana'; +export * from './ethereum'; diff --git a/packages/bridge/src/components/Input/solana.tsx b/packages/bridge/src/components/Input/solana.tsx new file mode 100644 index 0000000..c6193a6 --- /dev/null +++ b/packages/bridge/src/components/Input/solana.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useState } from 'react'; +import { contexts, utils, ParsedAccount, NumericInput, TokenIcon, TokenDisplay } from '@oyster/common'; +import { Card, Select } from 'antd'; +import './style.less'; +const { getTokenName } = utils; +const { cache } = contexts.Accounts; +const { useConnectionConfig } = contexts.Connection; + +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 function SolanaInput(props: { + title: string; + amount?: number | null; + disabled?: boolean; + onInputChange: (value: number | null) => void; + hideBalance?: boolean; + useWalletBalance?: boolean; + useFirstReserve?: boolean; + showLeverageSelector?: boolean; + leverage?: number; +}) { + const { tokenMap } = useConnectionConfig(); + const [acco, setCollateralReserve] = useState(); + const [balance, setBalance] = useState(0); + const [lastAmount, setLastAmount] = useState(''); + + + const renderReserveAccounts = [].map((reserve: any) => { + 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.disabled ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/packages/bridge/src/components/Input/style.less b/packages/bridge/src/components/Input/style.less new file mode 100644 index 0000000..8d01376 --- /dev/null +++ b/packages/bridge/src/components/Input/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/packages/bridge/src/components/Transfer/index.tsx b/packages/bridge/src/components/Transfer/index.tsx index e133274..9fecebd 100644 --- a/packages/bridge/src/components/Transfer/index.tsx +++ b/packages/bridge/src/components/Transfer/index.tsx @@ -4,6 +4,7 @@ import { LAMPORTS_PER_SOL } from '@solana/web3.js'; import { LABELS } from '../../constants'; import { contexts, utils, ConnectButton } from '@oyster/common'; import { useHistory, useLocation } from "react-router-dom"; +import { SolanaInput, EthereumInput } from "./../Input"; import './style.less'; @@ -15,46 +16,21 @@ export const Transfer = () => { const connection = useConnection(); const { wallet } = useWallet(); - const tabStyle: React.CSSProperties = { width: 120 }; - const tabList = [ - { - key: "eth", - tab:
Transfer
, - render: () => { - return
Bring assets to Solana
; - }, - }, - { - key: "sol", - tab:
Wrap
, - render: () => { - return
Bring assets to Solana
; - }, - }, - ]; - - const location = useLocation(); - const history = useHistory(); - const activeTab = location.pathname.indexOf("eth") < 0 ? "sol" : "eth"; - - const handleTabChange = (key: any) => { - if (activeTab !== key) { - if (key === "sol") { - history.push("/move/sol"); - } else { - history.push("/move/eth"); - } - } - }; return ( <>
- INPUT + {}} + /> - OUTPUT + {}} + />
Transfer diff --git a/packages/bridge/src/contexts/tokenPair.tsx b/packages/bridge/src/contexts/tokenPair.tsx new file mode 100644 index 0000000..5422fb0 --- /dev/null +++ b/packages/bridge/src/contexts/tokenPair.tsx @@ -0,0 +1,259 @@ +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { MintInfo } from "@solana/spl-token"; +import { useHistory, useLocation } from "react-router-dom"; +import bs58 from "bs58"; +import { KnownToken, TokenAccount } from "@oyster/common"; +import { useConnection, useConnectionConfig, useAccountByMint, useMint, getTokenName, getTokenIcon, convert } from "@oyster/common"; + +export interface TokenContextState { + mintAddress: string; + account?: TokenAccount; + mint?: MintInfo; + amount: string; + name: string; + icon?: string; + setAmount: (val: string) => void; + setMint: (mintAddress: string) => void; + convertAmount: () => number; + sufficientBalance: () => boolean; +} + +export interface TokenPairContextState { + A: TokenContextState; + B: TokenContextState; + lastTypedAccount: string; + setLastTypedAccount: (mintAddress: string) => void; +} + +const TokenPairContext = React.createContext( + null +); + +const convertAmount = (amount: string, mint?: MintInfo) => { + return parseFloat(amount) * Math.pow(10, mint?.decimals || 0); +}; + +export const useCurrencyLeg = (defaultMint?: string) => { + const { tokenMap } = useConnectionConfig(); + const [amount, setAmount] = useState(""); + const [mintAddress, setMintAddress] = useState(defaultMint || ""); + const account = useAccountByMint(mintAddress); + const mint = useMint(mintAddress); + + return useMemo( + () => ({ + mintAddress: mintAddress, + account: account, + mint: mint, + amount: amount, + name: getTokenName(tokenMap, mintAddress), + icon: getTokenIcon(tokenMap, mintAddress), + setAmount: setAmount, + setMint: setMintAddress, + convertAmount: () => convertAmount(amount, mint), + sufficientBalance: () => + account !== undefined && + (convert(account, mint) >= parseFloat(amount)) + }), + [ + mintAddress, + account, + mint, + amount, + tokenMap, + setAmount, + setMintAddress, + ] + ); +}; + +export function CurrencyPairProvider({ children = null as any }) { + const connection = useConnection(); + const { tokens } = useConnectionConfig(); + + const history = useHistory(); + const location = useLocation(); + const [lastTypedAccount, setLastTypedAccount] = useState(""); + + const base = useCurrencyLeg(); + const mintAddressA = base.mintAddress; + const setMintAddressA = base.setMint; + const amountA = base.amount; + const setAmountA = base.setAmount; + + const quote = useCurrencyLeg(); + const mintAddressB = quote.mintAddress; + const setMintAddressB = quote.setMint; + const amountB = quote.amount; + const setAmountB = quote.setAmount; + + useEffect(() => { + const base = + tokens.find((t) => t.mintAddress === mintAddressA)?.tokenSymbol || + mintAddressA; + const quote = + tokens.find((t) => t.mintAddress === mintAddressB)?.tokenSymbol || + mintAddressB; + + document.title = `Swap | Serum (${base}/${quote})`; + }, [mintAddressA, mintAddressB, tokens, location]); + + // updates browser history on token changes + useEffect(() => { + // set history + const base = + tokens.find((t) => t.mintAddress === mintAddressA)?.tokenSymbol || + mintAddressA; + const quote = + tokens.find((t) => t.mintAddress === mintAddressB)?.tokenSymbol || + mintAddressB; + + if (base && quote && location.pathname.indexOf("info") < 0) { + history.push({ + search: `?pair=${base}-${quote}`, + }); + } else { + if (mintAddressA && mintAddressB) { + history.push({ + search: ``, + }); + } else { + return; + } + } + }, [mintAddressA, mintAddressB, tokens, history, location.pathname]); + + // Updates tokens on location change + useEffect(() => { + if (!location.search && mintAddressA && mintAddressB) { + return; + } + + let { defaultBase, defaultQuote } = getDefaultTokens( + tokens, + location.search + ); + if (!defaultBase || !defaultQuote) { + return; + } + + setMintAddressA( + tokens.find((t) => t.tokenSymbol === defaultBase)?.mintAddress || + (isValidAddress(defaultBase) ? defaultBase : "") || + "" + ); + setMintAddressB( + tokens.find((t) => t.tokenSymbol === defaultQuote)?.mintAddress || + (isValidAddress(defaultQuote) ? defaultQuote : "") || + "" + ); + // mintAddressA and mintAddressB are not included here to prevent infinite loop + // eslint-disable-next-line + }, [location, location.search, setMintAddressA, setMintAddressB, tokens]); + + const calculateDependent = useCallback(async () => { + if (mintAddressA && mintAddressB) { + let setDependent; + let amount; + let independent; + if (lastTypedAccount === mintAddressA) { + independent = mintAddressA; + setDependent = setAmountB; + amount = parseFloat(amountA); + } else { + independent = mintAddressB; + setDependent = setAmountA; + amount = parseFloat(amountB); + } + + // TODO: calculate + const result: number | string = 0; + if (typeof result === "string") { + setDependent(result); + } else if (result !== undefined && Number.isFinite(result)) { + setDependent(result.toFixed(6)); + } else { + setDependent(""); + } + } + }, [ + mintAddressA, + mintAddressB, + setAmountA, + setAmountB, + amountA, + amountB, + connection, + lastTypedAccount, + ]); + + useEffect(() => { + calculateDependent(); + }, [amountB, amountA, lastTypedAccount, calculateDependent]); + + return ( + + {children} + + ); +} + +export const useCurrencyPairState = () => { + const context = useContext(TokenPairContext); + + return context as TokenPairContextState; +}; + +const isValidAddress = (address: string) => { + try { + const decoded = bs58.decode(address); + return decoded.length === 32; + } catch { + return false; + } +}; + +function getDefaultTokens(tokens: KnownToken[], search: string) { + let defaultBase = "SOL"; + let defaultQuote = "USDC"; + + const nameToToken = tokens.reduce((map, item) => { + map.set(item.tokenSymbol, item); + return map; + }, new Map()); + + if (search) { + const urlParams = new URLSearchParams(search); + const pair = urlParams.get("pair"); + if (pair) { + let items = pair.split("-"); + + if (items.length > 1) { + if (nameToToken.has(items[0]) || isValidAddress(items[0])) { + defaultBase = items[0]; + } + + if (nameToToken.has(items[1]) || isValidAddress(items[1])) { + defaultQuote = items[1]; + } + } + } + } + return { + defaultBase, + defaultQuote, + }; +} diff --git a/packages/bridge/src/views/transfer/index.tsx b/packages/bridge/src/views/transfer/index.tsx index 23e8ba6..e8f4cf6 100644 --- a/packages/bridge/src/views/transfer/index.tsx +++ b/packages/bridge/src/views/transfer/index.tsx @@ -4,6 +4,7 @@ import { LAMPORTS_PER_SOL } from '@solana/web3.js'; import { LABELS } from '../../constants'; import { contexts, utils, ConnectButton } from '@oyster/common'; import { useHistory, useLocation } from "react-router-dom"; +import { Transfer } from '../../components/Transfer'; const { useConnection } = contexts.Connection; const { useWallet } = contexts.Wallet; const { notify } = utils; @@ -18,7 +19,7 @@ export const TransferView = () => { key: "eth", tab:
Transfer
, render: () => { - return
Bring assets to Solana
; + return ; }, }, { @@ -32,7 +33,7 @@ export const TransferView = () => { const location = useLocation(); const history = useHistory(); - const activeTab = location.pathname.indexOf("eth") < 0 ? "sol" : "eth"; + const activeTab = location.pathname.indexOf("sol") >= 0 ? "sol" : "eth"; const handleTabChange = (key: any) => { if (activeTab !== key) { diff --git a/packages/common/package.json b/packages/common/package.json index 119d7c2..457036d 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -5,7 +5,7 @@ "main": "dist/lib/index.js", "types": "dist/lib/index.d.ts", "exports": { - ".": "./dist/lib/index.js" + ".": "./dist/lib/" }, "license": "Apache-2.0", "publishConfig": { diff --git a/packages/common/src/contexts/index.tsx b/packages/common/src/contexts/index.tsx index 6632606..e8082d5 100644 --- a/packages/common/src/contexts/index.tsx +++ b/packages/common/src/contexts/index.tsx @@ -2,3 +2,7 @@ export * as Accounts from './accounts'; export * as Connection from './connection'; export * as Wallet from './wallet'; export { ParsedAccount, ParsedAccountBase } from './accounts'; + +export * from './accounts'; +export * from './wallet'; +export * from './connection'; diff --git a/packages/common/src/index.tsx b/packages/common/src/index.tsx index 29b4c93..1b95912 100644 --- a/packages/common/src/index.tsx +++ b/packages/common/src/index.tsx @@ -4,9 +4,12 @@ export * from './components'; // Allow direct exports too export * as config from './config'; export * as constants from './constants'; export * as hooks from './hooks'; +export * from './hooks'; export * as contexts from './contexts'; +export * from './contexts'; export * as models from './models'; export * as utils from './utils'; +export * from './utils'; export * as walletAdapters from './wallet-adapters'; export { TokenAccount } from './models';