feat: bridge

This commit is contained in:
bartosz-lipinski 2021-02-15 19:08:42 -06:00
parent f634558f9c
commit 752f342781
10 changed files with 577 additions and 36 deletions

View File

@ -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<string>();
const [balance, setBalance] = useState<number>(0);
const [lastAmount, setLastAmount] = useState<string>('');
const renderReserveAccounts = [].map((reserve: any) => {
const mint = reserve.info.liquidityMint.toBase58();
const address = reserve.pubkey.toBase58();
const name = getTokenName(tokenMap, mint);
return (
<Option key={address} value={address} name={name} title={address}>
<div key={address} style={{ display: 'flex', alignItems: 'center' }}>
<TokenIcon mintAddress={mint} />
{name}
</div>
</Option>
);
});
return (
<Card
className="ccy-input"
style={{ borderRadius: 20 }}
bodyStyle={{ padding: 0 }}
>
<div className="ccy-input-header">
<div className="ccy-input-header-left">{props.title}</div>
{!props.hideBalance && (
<div
className="ccy-input-header-right"
onClick={e => props.onInputChange && props.onInputChange(balance)}
>
Balance: {balance.toFixed(6)}
</div>
)}
</div>
<div className="ccy-input-header" style={{ padding: '0px 10px 5px 7px' }}>
<NumericInput
value={
parseFloat(lastAmount || '0.00') === props.amount
? lastAmount
: props.amount?.toFixed(6)?.toString()
}
onChange={(val: string) => {
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"
/>
<div className="ccy-input-header-right" style={{ display: 'flex' }}>
{!props.disabled ? (
<Select
size="large"
showSearch
style={{ minWidth: 150 }}
placeholder="CCY"
// value={collateralReserve}
// onChange={item => {
// if (props.onCollateralReserve) props.onCollateralReserve(item);
// setCollateralReserve(item);
// }}
filterOption={(input, option) =>
option?.name?.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{renderReserveAccounts}
</Select>
) : (
<TokenDisplay
// key={props.reserve.liquidityMint.toBase58()}
name={getTokenName(
tokenMap,
'',
)}
mintAddress={''}
showBalance={false}
/>
)}
</div>
</div>
</Card>
);
}

View File

@ -0,0 +1,2 @@
export * from './solana';
export * from './ethereum';

View File

@ -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<string>();
const [balance, setBalance] = useState<number>(0);
const [lastAmount, setLastAmount] = useState<string>('');
const renderReserveAccounts = [].map((reserve: any) => {
const mint = reserve.info.liquidityMint.toBase58();
const address = reserve.pubkey.toBase58();
const name = getTokenName(tokenMap, mint);
return (
<Option key={address} value={address} name={name} title={address}>
<div key={address} style={{ display: 'flex', alignItems: 'center' }}>
<TokenIcon mintAddress={mint} />
{name}
</div>
</Option>
);
});
return (
<Card
className="ccy-input"
style={{ borderRadius: 20 }}
bodyStyle={{ padding: 0 }}
>
<div className="ccy-input-header">
<div className="ccy-input-header-left">{props.title}</div>
{!props.hideBalance && (
<div
className="ccy-input-header-right"
onClick={e => props.onInputChange && props.onInputChange(balance)}
>
Balance: {balance.toFixed(6)}
</div>
)}
</div>
<div className="ccy-input-header" style={{ padding: '0px 10px 5px 7px' }}>
<NumericInput
value={
parseFloat(lastAmount || '0.00') === props.amount
? lastAmount
: props.amount?.toFixed(6)?.toString()
}
onChange={(val: string) => {
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"
/>
<div className="ccy-input-header-right" style={{ display: 'flex' }}>
{!props.disabled ? (
<Select
size="large"
showSearch
style={{ minWidth: 150 }}
placeholder="CCY"
// value={collateralReserve}
// onChange={item => {
// if (props.onCollateralReserve) props.onCollateralReserve(item);
// setCollateralReserve(item);
// }}
filterOption={(input, option) =>
option?.name?.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{renderReserveAccounts}
</Select>
) : (
<TokenDisplay
// key={props.reserve.liquidityMint.toBase58()}
name={getTokenName(
tokenMap,
'',
)}
mintAddress={''}
showBalance={false}
/>
)}
</div>
</div>
</Card>
);
}

View File

@ -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;
}

View File

@ -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: <div style={tabStyle}>Transfer</div>,
render: () => {
return <div>Bring assets to Solana</div>;
},
},
{
key: "sol",
tab: <div style={tabStyle}>Wrap</div>,
render: () => {
return <div>Bring assets to Solana</div>;
},
},
];
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 (
<>
<div className="exchange-card">
INPUT
<EthereumInput
title="From Ethereum"
onInputChange={() => {}}
/>
<Button type="primary" className="swap-button">
</Button>
OUTPUT
<SolanaInput
title="To Solana"
onInputChange={() => {}}
/>
</div>
<ConnectButton type="primary">
Transfer

View File

@ -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<TokenPairContextState | null>(
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 (
<TokenPairContext.Provider
value={{
A: base,
B: quote,
lastTypedAccount,
setLastTypedAccount,
}}
>
{children}
</TokenPairContext.Provider>
);
}
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<string, any>());
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,
};
}

View File

@ -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: <div style={tabStyle}>Transfer</div>,
render: () => {
return <div>Bring assets to Solana</div>;
return <Transfer />;
},
},
{
@ -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) {

View File

@ -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": {

View File

@ -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';

View File

@ -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';