Initial commit

This commit is contained in:
Nishad 2020-08-28 07:20:09 +08:00
commit 0f11c09321
60 changed files with 15945 additions and 0 deletions

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.idea

13
LICENSE Normal file
View File

@ -0,0 +1,13 @@
Copyright 2020 Serum Foundation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

17
README.md Normal file
View File

@ -0,0 +1,17 @@
# Serum DEX UI
An implementation of a UI for the Serum DEX.
See [A technical introduction to the Serum DEX](https://projectserum.com/blog/serum-dex-introduction) to learn more about the Serum DEX.
See [serum-js](https://github.com/project-serum/serum-js) for DEX client-side code. Serum DEX UI uses this library.
See [sol-wallet-adapter](https://github.com/project-serum/sol-wallet-adapter) for an explanation of how the Serum DEX UI interacts with wallet services to sign and send requests to the Serum DEX.
See [spl-token-wallet](https://github.com/project-serum/spl-token-wallet) for an implementation of such a wallet, live at [sollet.io](https://sollet.io).
---
Run `yarn start` to start a development server or `yarn build` to create a production build that can be served by a static file server.
See the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started) for other commands and options.

17
craco.config.js Normal file
View File

@ -0,0 +1,17 @@
const CracoLessPlugin = require('craco-less');
module.exports = {
plugins: [
{
plugin: CracoLessPlugin,
options: {
lessLoaderOptions: {
lessOptions: {
modifyVars: { '@primary-color': '#2abdd2' },
javascriptEnabled: true,
},
},
},
},
],
};

74
package.json Normal file
View File

@ -0,0 +1,74 @@
{
"name": "serum-dex-ui",
"version": "0.1.0",
"private": true,
"dependencies": {
"@ant-design/icons": "^4.2.1",
"@craco/craco": "^5.6.4",
"@project-serum/serum": "^0.6.9",
"@project-serum/sol-wallet-adapter": "^0.1.0",
"@solana/web3.js": "^0.71.9",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@tsconfig/node12": "^1.0.7",
"antd": "^4.6.0",
"bn.js": "^5.1.3",
"craco-less": "^1.17.0",
"immutable-tuple": "^0.4.10",
"qrcode.react": "^1.0.0",
"react": "^16.13.1",
"react-app-polyfill": "^1.0.5",
"react-copy-to-clipboard": "^5.0.2",
"react-dom": "^16.13.1",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-scripts": "3.4.3",
"styled-components": "^5.1.1"
},
"scripts": {
"start": "craco start",
"build": "craco build",
"test": "craco test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"jest": {
"transformIgnorePatterns": [
"^.+\\.cjs$"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"prettier": {
"singleQuote": true,
"trailingComma": "all"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"devDependencies": {
"git-format-staged": "^2.1.0",
"husky": "^4.2.5",
"lint-staged": ">=10",
"prettier": "^2.0.5",
"typescript": "^4.0.2"
},
"lint-staged": {
"*.{js,css,md}": "prettier --write"
}
}

1
public/CNAME Normal file
View File

@ -0,0 +1 @@
demo.projectserum.com

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 B

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

72
public/index.html Normal file
View File

@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/logo.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="keywords"
content="Serum, SRM, Serum DEX, DEFI, Decentralized Finance, Decentralised Finance, Crypto, ERC20, Ethereum, Decentralize, Solana, SOL, SPL, Cross-Chain, Trading, Fastest, Fast, SerumBTC, SerumUSD, SRM Tokens, SPL Tokens"
/>
<meta
name="description"
content="Serum - The world's first completely decentralized derivatives exchange with trustless cross-chain trading"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="%PUBLIC_URL%/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="%PUBLIC_URL%/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="%PUBLIC_URL%/favicon-16x16.png"
/>
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#ffffff" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Serum DEX" />
<meta
name="twitter:description"
content="Serum DEX - The world's first completely decentralized derivatives exchange with trustless cross-chain trading"
/>
<meta name="twitter:image" content="https://i.imgur.com/YS5Csfy.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Serum</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
public/logo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
public/manifest.json Normal file
View File

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

31
src/App.js Normal file
View File

@ -0,0 +1,31 @@
import React, { Suspense } from 'react';
import './App.less';
import { ConnectionProvider } from './utils/connection';
import { MarketProvider } from './utils/markets';
import { WalletProvider } from './utils/wallet';
import TradePage from './pages/TradePage';
import BasicLayout from './components/BasicLayout';
import { GlobalStyle } from './global_style';
import { Spin } from 'antd';
import ErrorBoundary from './components/ErrorBoundary';
export default function App() {
return (
<Suspense fallback={() => <Spin size="large" />}>
<GlobalStyle />
<ErrorBoundary>
<ConnectionProvider>
<MarketProvider>
<WalletProvider>
<BasicLayout>
<Suspense fallback={() => <Spin size="large" />}>
<TradePage />
</Suspense>
</BasicLayout>
</WalletProvider>
</MarketProvider>
</ConnectionProvider>
</ErrorBoundary>
</Suspense>
);
}

40
src/App.less Normal file
View File

@ -0,0 +1,40 @@
@import '~antd/dist/antd.dark.less';
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

9
src/App.test.js Normal file
View File

@ -0,0 +1,9 @@
import React from 'react';
import { render } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
const { getByText } = render(<App />);
const linkElement = getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

3
src/ant-custom.less Normal file
View File

@ -0,0 +1,3 @@
@import '~antd/dist/antd.css';
@import '~antd/dist/antd.dark.less';
@primary-color: #2abdd2;

1
src/assets/logo.svg Normal file
View File

@ -0,0 +1 @@
<svg enable-background="new 0 0 251 286" viewBox="0 0 251 286" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a"><stop offset="0" stop-color="#05aac5"/><stop offset="1" stop-color="#71e0ec"/></linearGradient><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="125.4996" x2="125.4996" xlink:href="#a" y1="234.9332" y2="33.9501"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="125.5" x2="125.5" xlink:href="#a" y1="259.8856" y2="17.6906"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="125.5" x2="125.5" xlink:href="#a" y1="284.8729" y2=".9991"/><path d="m125.5 234.9c-43.1 0-78.1-35-78.1-78.1 0-41.9 70.7-115.9 73.7-119l3.7-3.9 3.8 3.8c3.1 3.1 75 77.1 75 119 0 43.2-35 78.2-78.1 78.2zm-.6-185.9c-6.2 6.7-19.2 21.2-32.1 38.2-22.9 30.2-35 54.2-35 69.5 0 37.3 30.4 67.7 67.7 67.7s67.7-30.4 67.7-67.7c0-15.3-12.3-39.3-35.6-69.5-13.1-16.9-26.4-31.5-32.7-38.2z" fill="url(#b)"/><path d="m125.5 259.9c-56.9 0-103.1-46.3-103.1-103.1 0-55.6 77.6-133.2 80.9-136.4 2-2 5.4-2 7.4 0s2 5.4 0 7.4c-.8.8-77.8 77.8-77.8 129 0 51.1 41.6 92.7 92.7 92.7s92.7-41.6 92.7-92.7c0-21-13.6-50-39.4-84.1-19.8-26.1-39.7-45.9-39.9-46.1-2.1-2-2.1-5.3 0-7.4 2-2.1 5.3-2.1 7.4 0 3.4 3.3 82.4 81.9 82.4 137.6-.2 56.8-46.4 103.1-103.3 103.1z" fill="url(#c)"/><path d="m125.5 284.9c-69.2 0-125.5-56.3-125.5-125.5 0-28.6 14.4-63.8 42.7-104.5 20.9-30 41.4-51.4 42.3-52.3 2-2.1 5.3-2.1 7.4-.1s2.1 5.3.1 7.4c-.2.2-20.9 21.8-41.3 51.1-26.7 38.3-40.8 72.3-40.8 98.4 0 63.4 51.6 115.1 115.1 115.1s115.1-51.6 115.1-115.1c0-26.1-14.2-60.1-41.1-98.4-20.6-29.3-41.5-50.9-41.7-51.1-2-2.1-2-5.4.1-7.4s5.4-2 7.4.1c3.5 3.6 85.7 89 85.7 156.8 0 69.2-56.3 125.5-125.5 125.5z" fill="url(#d)"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,21 @@
import { Layout } from 'antd';
import React from 'react';
import TopBar from './TopBar';
import { CustomFooter as Footer } from './Footer';
const { Header, Content } = Layout;
export default function BasicLayout({ children }) {
return (
<React.Fragment>
<Layout
style={{ display: 'flex', minHeight: '100vh', flexDirection: 'column' }}
>
<Header style={{ padding: 0 }}>
<TopBar />
</Header>
<Content style={{ flex: 1 }}>{children}</Content>
<Footer />
</Layout>
</React.Fragment>
);
}

View File

@ -0,0 +1,13 @@
import React from 'react';
import { Row, Col } from 'antd';
import DexProgramSelector from './DexProgramSelector';
export default function BottomBar() {
return (
<Row justify="end">
<Col>
<DexProgramSelector />
</Col>
</Row>
);
}

View File

@ -0,0 +1,51 @@
import React from 'react';
import { Modal } from 'antd';
import {
COIN_MINTS,
useSelectedBaseCurrencyAccount,
useMarket,
useSelectedQuoteCurrencyAccount,
} from '../utils/markets';
import { useWallet } from '../utils/wallet';
export default function DepositDialog({ onClose, depositCoin }) {
let coinMint =
depositCoin &&
Object.keys(COIN_MINTS).find(
(address) => COIN_MINTS[address] === depositCoin,
);
const { market } = useMarket();
const [, , , , providerName] = useWallet();
const baseCurrencyAccount = useSelectedBaseCurrencyAccount();
const quoteCurrencyAccount = useSelectedQuoteCurrencyAccount();
let account;
if (market?.baseMintAddress?.toBase58() === coinMint) {
account = baseCurrencyAccount;
} else {
account = quoteCurrencyAccount;
}
if (!coinMint) {
return null;
}
return (
<Modal
title={depositCoin}
visible={!!coinMint}
onOk={onClose}
onCancel={onClose}
>
<div style={{ paddingTop: '20px' }}>
<p style={{ color: 'white' }}>Mint address:</p>
<p style={{ color: 'rgba(255,255,255,0.5)' }}>{coinMint}</p>
<div>
<p style={{ color: 'white' }}>Deposit address:</p>
<p style={{ color: 'rgba(255,255,255,0.5)' }}>
{account
? account.pubkey.toBase58()
: `Visit ${providerName} to create an account for this mint`}
</p>
</div>
</div>
</Modal>
);
}

View File

@ -0,0 +1,115 @@
import React, { useState } from 'react';
import { DEX_PROGRAM_ID } from '@project-serum/serum';
import { Row, Col, Select, Divider, Input, Button } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { useLocalStorageState } from '../utils/utils';
import { useMarket } from '../utils/markets';
import { notify } from '../utils/notifications';
const { Option } = Select;
const defaultProgramId = {
name: 'Default',
address: DEX_PROGRAM_ID.toBase58(),
};
export default function DexProgramSelector() {
const { selectedDexProgramID, setSelectedDexProgramID } = useMarket();
const [programIDs, setProgramIDs] = useLocalStorageState('dexProgramIDs', [
defaultProgramId,
]);
const [dexProgramName, setDexProgramName] = useState(null);
const [dexProgramAddress, setDexProgramAddress] = useState(null);
const onProgramIDChange = (name) => {
const selectedProgramID = programIDs.find(
(program) => program.name === name,
);
selectedProgramID && setSelectedDexProgramID(selectedProgramID.address);
};
const onAddDexProgramID = () => {
try {
const existingProgram = (programIDs || []).find(
(program) =>
program.address === dexProgramAddress ||
program.name === dexProgramName,
);
if (existingProgram) {
notify({
message:
'Name or address already exists (' + existingProgram.name + ')',
type: 'info',
});
return;
}
const programs = [
...programIDs,
{ name: dexProgramName, address: dexProgramAddress },
];
setProgramIDs(programs);
setDexProgramName(null);
setDexProgramAddress(null);
} catch (e) {
notify({ message: e.message, type: 'error' });
}
};
const selectedValue = programIDs.find(
(program) => program.address === selectedDexProgramID,
)?.name;
return (
<Select
onChange={onProgramIDChange}
style={{ width: 240 }}
defaultValue={selectedValue}
dropdownRender={(menu) => (
<>
<Row>
<Col span={24}>{menu}</Col>
</Row>
<Row>
<Col span={24}>
<Divider style={{ margin: '10px 0px' }} />
<div style={{ margin: '5px' }}>
<Input
placeholder="Name"
value={dexProgramName}
onChange={(e) => setDexProgramName(e.target.value)}
/>
<Input
style={{ marginTop: '5px' }}
placeholder="Address"
value={dexProgramAddress}
onChange={(e) => setDexProgramAddress(e.target.value)}
/>
</div>
</Col>
</Row>
<Row justify="end">
<Col>
<Button
disabled={!dexProgramAddress || !dexProgramName}
onClick={onAddDexProgramID}
block
type="primary"
icon={<PlusOutlined />}
>
Add program
</Button>
</Col>
</Row>
</>
)}
>
{(programIDs || []).map((item, index) => (
<Option key={index + ''} value={item.name}>
{item.name}
</Option>
))}
</Select>
);
}

View File

@ -0,0 +1,29 @@
import React, { Component } from 'react';
import { Typography } from 'antd';
const { Title } = Typography;
export default class ErrorBoundary extends Component {
state = {
hasError: false,
};
static getDerivedStateFromError(error) {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return (
<div>
<div>
<Title level={2}>Something went wrong.</Title>
<Title level={4}>Please try again later.</Title>
</div>
</div>
);
}
return this.props.children;
}
}

49
src/components/Footer.jsx Normal file
View File

@ -0,0 +1,49 @@
import React from 'react';
import { Layout, Row, Col, Grid } from 'antd';
import Link from './Link';
import { helpUrls } from './HelpUrls';
const { Footer } = Layout;
const { useBreakpoint } = Grid;
const footerElements = [
{
description: 'Serum Developer Resources',
link: helpUrls.developerResources,
},
{ description: 'Discord Group', link: helpUrls.discord },
{ description: 'GitHub', link: helpUrls.github },
{ description: 'Project Serum', link: helpUrls.projectSerum },
{ description: 'Solana Network', link: helpUrls.solanaBeach },
];
export const CustomFooter = () => {
const smallScreen = !useBreakpoint().lg;
return (
<Footer
style={{
height: '45px',
paddingBottom: 10,
paddingTop: 10,
}}
>
<Row align="middle" gutter={[16, 4]}>
{!smallScreen && (
<>
<Col flex="auto" />
{footerElements.map((elem, index) => {
return (
<Col key={index + ''}>
<Link external to={elem.link}>
{elem.description}
</Link>
</Col>
);
})}
</>
)}
<Col flex="auto">{/* <DexProgramSelector />*/}</Col>
</Row>
</Footer>
);
};

View File

@ -0,0 +1,10 @@
export const helpUrls = {
customerSupport: 'https://t.me/ProjectSerum',
customerSupportZh: 'https://t.me/ProjectSerum_Chinese',
contactEmail: 'mailto:contact@projectserum.com',
discord: 'https://discord.gg/MxZFT4v',
github: 'https://github.com/project-serum',
projectSerum: 'https://projectserum.com/',
developerResources: 'https://projectserum.com/developer-resources',
solanaBeach: 'https://solanabeach.io',
};

18
src/components/Link.js Normal file
View File

@ -0,0 +1,18 @@
import React from 'react';
import { Link as RouterLink } from 'react-router-dom';
export default function Link({ external = false, ...props }) {
let { to, children, ...rest } = props;
if (external) {
return (
<a href={to} target="_blank" rel="noopener noreferrer" {...rest}>
{children}
</a>
);
}
return (
<RouterLink to={to} {...rest}>
{children}
</RouterLink>
);
}

View File

@ -0,0 +1,183 @@
import { Col, Row } from 'antd';
import React, { useRef, useEffect } from 'react';
import styled, { css } from 'styled-components';
import { useMarket, useOrderbook, useMarkPrice } from '../utils/markets';
import { isEqual, getDecimalCount } from '../utils/utils';
import FloatingElement from './layout/FloatingElement';
import usePrevious from '../utils/usePrevious';
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
const Title = styled.div`
color: rgba(255, 255, 255, 1);
`;
const SizeTitle = styled(Row)`
padding: 20px 0 14px;
color: #434a59;
`;
const MarkPriceTitle = styled(Row)`
padding: 20px 0 14px;
font-weight: 700;
`;
const Line = styled.div`
text-align: right;
float: right;
height: 100%;
${(props) =>
props['data-width'] &&
css`
width: ${props['data-width']};
`}
${(props) =>
props['data-bgcolor'] &&
css`
background-color: ${props['data-bgcolor']};
`}
`;
const Price = styled.div`
position: absolute;
right: 5px;
color: white;
`;
export default function Orderbook({ smallScreen, depth = 7, onPrice, onSize }) {
const markPrice = useMarkPrice();
const [orderbook, loaded] = useOrderbook();
const { baseCurrency, quoteCurrency } = useMarket();
let bids = [];
let asks = [];
if (loaded) {
bids = orderbook.bids;
asks = orderbook.asks;
}
function getCumulativeOrderbookSide(orders, backwards = false) {
let cumulative = [];
let cumulativeSize = 0;
orders.forEach(([price, size]) => {
if (cumulative.length < depth) {
cumulativeSize += size;
cumulative.push({ price, size, cumulativeSize });
}
});
if (backwards) {
cumulative = cumulative.reverse();
}
let totalSize =
cumulative.length > 0 &&
cumulative[backwards ? 0 : cumulative.length - 1].cumulativeSize;
return [cumulative, totalSize];
}
let [asksToDisplay, totalAskSize] = getCumulativeOrderbookSide(asks, true);
let [bidsToDisplay, totalBidSize] = getCumulativeOrderbookSide(bids, false);
let totalSize = totalAskSize + totalBidSize;
return (
<FloatingElement style={smallScreen ? { flex: 1 } : { height: '500px' }}>
<Title>Orderbook</Title>
<SizeTitle>
<Col span={12} style={{ textAlign: 'left' }}>
Size ({baseCurrency})
</Col>
<Col span={12} style={{ textAlign: 'right' }}>
Price ({quoteCurrency})
</Col>
</SizeTitle>
{asksToDisplay.map(({ price, size, cumulativeSize }) => (
<OrderbookRow
key={price}
price={price}
size={size}
side={'sell'}
sizePercent={(cumulativeSize / (totalSize || 1)) * 100}
onSizeClick={() => onSize(size)}
onPriceClick={() => onPrice(price)}
/>
))}
<MarkPriceComponent markPrice={markPrice} />
{bidsToDisplay.map(({ price, size, cumulativeSize }) => (
<OrderbookRow
key={price}
price={price}
size={size}
side={'buy'}
sizePercent={(cumulativeSize / (totalSize || 1)) * 100}
onSizeClick={() => onSize(size)}
onPriceClick={() => onPrice(price)}
/>
))}
</FloatingElement>
);
}
const OrderbookRow = React.memo(
({ side, price, size, sizePercent, onSizeClick, onPriceClick }) => {
const element = useRef();
useEffect(() => {
// eslint-disable-next-line
let _ = element.current?.classList.add('flash');
setTimeout(() => element.current?.classList.remove('flash'), 500);
}, [price, size]);
return (
<Row ref={element} id={price + ''} style={{ marginBottom: 1 }}>
<Col span={12} style={{ textAlign: 'left' }} onClick={onSizeClick}>
{size}
</Col>
<Col span={12} style={{ textAlign: 'right' }}>
<Line
data-width={sizePercent + '%'}
data-bgcolor={
side === 'buy'
? 'rgba(65, 199, 122, 0.6)'
: 'rgba(242, 60, 105, 0.6)'
}
/>
<Price onClick={onPriceClick}>{price}</Price>
</Col>
</Row>
);
},
(prevProps, nextProps) =>
isEqual(prevProps, nextProps, ['side', 'price', 'size', 'sizePercent']),
);
const MarkPriceComponent = React.memo(
({ markPrice }) => {
const { priceStep } = useMarket();
const previousMarkPrice = usePrevious(markPrice);
let markPriceColor =
markPrice > previousMarkPrice
? '#41C77A'
: markPrice < previousMarkPrice
? '#F23B69'
: 'white';
return (
<MarkPriceTitle justify="center">
<Col style={{ color: markPriceColor }}>
{markPrice > previousMarkPrice && (
<ArrowUpOutlined style={{ marginRight: 5 }} />
)}
{markPrice < previousMarkPrice && (
<ArrowDownOutlined style={{ marginRight: 5 }} />
)}
{markPrice
? priceStep
? markPrice.toFixed(getDecimalCount(priceStep))
: markPrice
: '----'}
</Col>
</MarkPriceTitle>
);
},
(prevProps, nextProps) => isEqual(prevProps, nextProps, ['markPrice']),
);

View File

@ -0,0 +1,98 @@
import { Button, Col, Row, Divider } from 'antd';
import React, { useState } from 'react';
import FloatingElement from './layout/FloatingElement';
import styled from 'styled-components';
import {
useBaseCurrencyBalances,
useQuoteCurrencyBalances,
useMarket,
} from '../utils/markets';
import DepositDialog from './DepositDialog';
import { useWallet, WALLET_PROVIDERS } from '../utils/wallet';
const RowBox = styled(Row)`
padding-bottom: 20px;
`;
const Tip = styled.p`
font-size: 12px;
color: #2abdd2;
padding-top: 6px;
`;
const ActionButton = styled(Button)`
color: #2abdd2;
background-color: #212734;
border-width: 0px;
`;
export default function StandaloneBalancesDisplay() {
const { baseCurrency, quoteCurrency } = useMarket();
const [baseCurrencyBalances] = useBaseCurrencyBalances();
const [quoteCurrencyBalances] = useQuoteCurrencyBalances();
let [, , providerUrl] = useWallet();
const [depositCoin, setDepositCoin] = useState('');
const providerName = WALLET_PROVIDERS.find(({ url }) => url === providerUrl)
?.name;
return (
<FloatingElement style={{ flex: 1, paddingTop: 10 }}>
<Divider style={{ borderColor: 'white' }}>{baseCurrency}</Divider>
<RowBox
align="middle"
justify="space-between"
style={{ paddingBottom: 12 }}
>
<Col>{baseCurrency} wallet balance:</Col>
<Col>{baseCurrencyBalances}</Col>
</RowBox>
<RowBox align="middle" justify="space-around">
<Col style={{ width: 150 }}>
<ActionButton
block
size="large"
onClick={() => setDepositCoin(baseCurrency)}
>
Deposit
</ActionButton>
</Col>
{/*<Col style={{ width: 150 }}>*/}
{/* <ActionButton block size="large">*/}
{/* Send*/}
{/* </ActionButton>*/}
{/*</Col>*/}
</RowBox>
<Tip>All deposits go to your {providerName} wallet</Tip>
<Divider>{quoteCurrency}</Divider>
<RowBox
align="middle"
justify="space-between"
style={{ paddingBottom: 12 }}
>
<Col>{quoteCurrency} wallet balance:</Col>
<Col>{quoteCurrencyBalances}</Col>
</RowBox>
<RowBox align="middle" justify="space-around">
<Col style={{ width: 150 }}>
<ActionButton
block
size="large"
onClick={() => setDepositCoin(quoteCurrency)}
>
Deposit
</ActionButton>
</Col>
{/*<Col style={{ width: 150 }}>*/}
{/* <ActionButton block size="large">*/}
{/* Send*/}
{/* </ActionButton>*/}
{/*</Col>*/}
</RowBox>
<Tip>All deposits go to your {providerName} wallet</Tip>
<DepositDialog
depositCoin={depositCoin}
onClose={() => setDepositCoin('')}
/>
</FloatingElement>
);
}

70
src/components/TopBar.js Normal file
View File

@ -0,0 +1,70 @@
import { UserOutlined } from '@ant-design/icons';
import { Button, Select } from 'antd';
import React from 'react';
import logo from '../assets/logo.svg';
import styled from 'styled-components';
import { useWallet, WALLET_PROVIDERS } from '../utils/wallet';
import { useConnectionConfig, ENDPOINTS } from '../utils/connection';
const Wrapper = styled.div`
background-color: #0d1017;
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 0px 30px;
`;
const LogoWrapper = styled.div`
display: flex;
align-items: center;
color: #2abdd2;
font-weight: bold;
cursor: pointer;
img {
height: 30px;
margin-right: 8px;
}
`;
export default function TopBar() {
const [connected, wallet, providerUrl, setProvider] = useWallet();
const { endpoint, setEndpoint } = useConnectionConfig();
return (
<Wrapper>
<div>
<LogoWrapper>
<img src={logo} alt="" />
{'SERUM'}
</LogoWrapper>
</div>
<div style={{ display: 'block' }}>
<Select
onSelect={setEndpoint}
value={endpoint}
style={{ marginRight: 8 }}
>
{ENDPOINTS.map(({ name, endpoint }) => (
<Select.Option value={endpoint} key={endpoint}>
{name}
</Select.Option>
))}
</Select>
<Select onSelect={setProvider} value={providerUrl}>
{WALLET_PROVIDERS.map(({ name, url }) => (
<Select.Option value={url} key={url}>
{name}
</Select.Option>
))}
</Select>
<Button
type="text"
size="large"
onClick={connected ? wallet.disconnect : wallet.connect}
style={{ color: '#2abdd2' }}
>
<UserOutlined />
{!connected ? 'Connect wallet' : 'Disconnect'}
</Button>
</div>
</Wrapper>
);
}

View File

@ -0,0 +1,247 @@
import { Button, Input, Radio, Switch, Slider } from 'antd';
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import {
useBaseCurrencyBalances,
useQuoteCurrencyBalances,
useMarket,
useMarkPrice,
useSelectedOpenOrdersAccount,
useSelectedBaseCurrencyAccount,
useSelectedQuoteCurrencyAccount,
} from '../utils/markets';
import { useWallet } from '../utils/wallet';
import { notify } from '../utils/notifications';
import { getDecimalCount } from '../utils/utils';
import { useConnection } from '../utils/connection';
import FloatingElement from './layout/FloatingElement';
import { placeOrder } from '../utils/send';
const InputBox = styled(Input)`
text-align: right;
padding-bottom: 16px;
`;
const SellButton = styled(Button)`
margin: 20px 0px 0px 0px;
background: #f23b69;
border-color: #f23b69;
`;
const BuyButton = styled(Button)`
margin: 20px 0px 0px 0px;
background: #02bf76;
border-color: #02bf76;
`;
const sliderMarks = {
0: '0%',
25: '25%',
50: '50%',
75: '75%',
100: '100%',
};
export default function TradeForm({ style, setChangeOrderRef }) {
const [side, setSide] = useState('buy');
const { baseCurrency, quoteCurrency, market } = useMarket();
const [baseCurrencyBalances] = useBaseCurrencyBalances();
const [quoteCurrencyBalances] = useQuoteCurrencyBalances();
const baseCurrencyAccount = useSelectedBaseCurrencyAccount();
const quoteCurrencyAccount = useSelectedQuoteCurrencyAccount();
const openOrdersAccount = useSelectedOpenOrdersAccount(true);
const [, wallet] = useWallet();
const connection = useConnection();
const markPrice = useMarkPrice();
const [postOnly, setPostOnly] = useState(false);
const [ioc, setIoc] = useState(false);
const [size, setSize] = useState(null);
const [price, setPrice] = useState(null);
const [submitting, setSubmitting] = useState(false);
const availableQuote = openOrdersAccount
? market.quoteSplSizeToNumber(openOrdersAccount.quoteTokenFree)
: 0;
const maxQuoteSize = quoteCurrencyBalances + availableQuote;
const sizeFraction =
(price && size && maxQuoteSize && baseCurrencyBalances && side === 'buy'
? ((price * size) / Math.floor(maxQuoteSize)) * 100
: (size / baseCurrencyBalances) * 100) || 0;
useEffect(() => {
setChangeOrderRef && setChangeOrderRef(doChangeOrder);
}, [setChangeOrderRef]);
useEffect(() => {
sizeFraction && onSliderChange(sizeFraction);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [side, sizeFraction]);
const doChangeOrder = ({ size, price }) => {
size && setSize(size);
price && setPrice(price);
};
const onSliderChange = (value) => {
if (!maxQuoteSize || !baseCurrencyBalances) {
return;
}
if (!price) {
markPrice && setPrice(markPrice);
}
let newSize;
if (side === 'buy') {
if (price || markPrice) {
newSize =
((Math.floor(maxQuoteSize) / (price || markPrice)) * value) / 100;
}
} else {
newSize = (Math.floor(baseCurrencyBalances) * value) / 100;
}
setSize(
market?.minOrderSize
? newSize.toFixed(getDecimalCount(market.minOrderSize))
: newSize,
);
};
const postOnChange = (checked) => {
if (checked) {
setIoc(false);
}
setPostOnly(checked);
};
const iocOnChange = (checked) => {
if (checked) {
setPostOnly(false);
}
setIoc(checked);
};
async function onSubmit() {
const parsedPrice = parseFloat(price);
const parsedSize = parseFloat(size);
setSubmitting(true);
try {
!(await placeOrder({
side,
price: parsedPrice,
size: parsedSize,
orderType: ioc ? 'ioc' : postOnly ? 'postOnly' : 'limit',
market,
connection,
wallet,
baseCurrencyAccount: baseCurrencyAccount?.pubkey?.toBase58(),
quoteCurrencyAccount: quoteCurrencyAccount?.pubkey?.toBase58(),
openOrdersAccount: openOrdersAccount?.pubkey?.toBase58(),
callback: () => setSubmitting(false),
})) && setSubmitting(false);
} catch (e) {
notify({ message: 'Error placing order: ' + e.message, type: 'error' });
setSubmitting(false);
}
}
return (
<FloatingElement
style={{ display: 'flex', flexDirection: 'column', ...style }}
>
<div style={{ flex: 1 }}>
<Radio.Group
onChange={(e) => setSide(e.target.value)}
value={side}
buttonStyle="solid"
style={{
marginBottom: 8,
width: '100%',
}}
>
<Radio.Button
value="buy"
style={{
width: '50%',
textAlign: 'center',
background: side === 'buy' ? '#02bf76' : '',
borderColor: side === 'buy' ? '#02bf76' : '',
}}
>
BUY
</Radio.Button>
<Radio.Button
value="sell"
style={{
width: '50%',
textAlign: 'center',
background: side === 'sell' ? '#F23B69' : '',
borderColor: side === 'sell' ? '#F23B69' : '',
}}
>
SELL
</Radio.Button>
</Radio.Group>
<InputBox
addonBefore={`Price (${quoteCurrency})`}
placeholder="Price"
value={price}
type="number"
step={market?.tickSize || 1}
onChange={(e) => setPrice(e.target.value)}
/>
<InputBox
addonBefore={`Size (${baseCurrency})`}
placeholder="Size"
value={size}
type="number"
step={market?.minOrderSize || 1}
onChange={(e) => setSize(e.target.value)}
/>
<Slider
value={sizeFraction}
tipFormatter={(value) => `${value}%`}
marks={sliderMarks}
onChange={onSliderChange}
/>
<div style={{ paddingTop: 18 }}>
{'POST '}
<Switch
checked={postOnly}
onChange={postOnChange}
style={{ marginRight: 40 }}
/>
{'IOC '}
<Switch checked={ioc} onChange={iocOnChange} />
</div>
</div>
{side === 'buy' ? (
<BuyButton
disabled={!price || !size}
onClick={onSubmit}
block
type="primary"
size="large"
loading={submitting}
>
Buy {baseCurrency}
</BuyButton>
) : (
<SellButton
disabled={!price || !size}
onClick={onSubmit}
block
type="primary"
size="large"
loading={submitting}
>
Sell {baseCurrency}
</SellButton>
)}
</FloatingElement>
);
}

View File

@ -0,0 +1,65 @@
import { Col, Row } from 'antd';
import React from 'react';
import styled from 'styled-components';
import { useMarket, useTrades } from '../utils/markets';
import FloatingElement from './layout/FloatingElement';
const Title = styled.div`
color: rgba(255, 255, 255, 1);
`;
const SizeTitle = styled(Row)`
padding: 20px 0 14px;
color: #434a59;
`;
// TODO: Put in some scrolling
const TradesContainer = styled.div`
height: calc(100% - 75px);
margin-right: -20px;
padding-right: 5px;
overflow-y: scroll;
`;
export default function PublicTrades({ smallScreen }) {
const { baseCurrency, quoteCurrency } = useMarket();
const trades = useTrades();
return (
<FloatingElement
style={
smallScreen
? { flex: 1 }
: { marginTop: '10px', height: 'calc(100% - 520px)' }
}
>
<Title>Market trades</Title>
<SizeTitle>
<Col span={12} style={{ textAlign: 'left' }}>
Size ({baseCurrency})
</Col>
<Col span={12} style={{ textAlign: 'right' }}>
Price ({quoteCurrency}){' '}
</Col>
</SizeTitle>
{!!trades && (
<TradesContainer>
{trades.map((trade, i) => (
<Row key={i} style={{ marginBottom: 4 }}>
<Col span={12} style={{ textAlign: 'left' }}>
{trade.size}
</Col>
<Col
span={12}
style={{
textAlign: 'right',
color: trade.side === 'buy' ? '#41C77A' : '#F23B69',
}}
>
{trade.price}
</Col>
</Row>
))}
</TradesContainer>
)}
</FloatingElement>
);
}

View File

@ -0,0 +1,121 @@
import { Button } from 'antd';
import React from 'react';
import {
useBaseCurrencyBalances,
useQuoteCurrencyBalances,
useSelectedOpenOrdersAccount,
useMarket,
useSelectedBaseCurrencyAccount,
useSelectedQuoteCurrencyAccount,
} from '../../utils/markets';
import DataTable from '../layout/DataTable';
import { useConnection } from '../../utils/connection';
import { useWallet } from '../../utils/wallet';
import { settleFunds } from '../../utils/send';
export default function BalancesTable() {
const [baseCurrencyBalances] = useBaseCurrencyBalances();
const [quoteCurrencyBalances] = useQuoteCurrencyBalances();
const baseCurrencyAccount = useSelectedBaseCurrencyAccount();
const quoteCurrencyAccount = useSelectedQuoteCurrencyAccount();
const connection = useConnection();
const [, wallet] = useWallet();
const openOrdersAccount = useSelectedOpenOrdersAccount(true);
const { baseCurrency, quoteCurrency, market } = useMarket();
const baseExists =
openOrdersAccount &&
openOrdersAccount.baseTokenTotal &&
openOrdersAccount.baseTokenFree;
const quoteExists =
openOrdersAccount &&
openOrdersAccount.quoteTokenTotal &&
openOrdersAccount.quoteTokenFree;
const dataSource = [
{
key: baseCurrency,
coin: baseCurrency,
wallet: baseCurrencyBalances,
orders:
baseExists && market
? market.baseSplSizeToNumber(
openOrdersAccount.baseTokenTotal.sub(
openOrdersAccount.baseTokenFree,
),
)
: null,
unsettled:
baseExists && market
? market.baseSplSizeToNumber(openOrdersAccount.baseTokenFree)
: null,
},
{
key: quoteCurrency,
coin: quoteCurrency,
wallet: quoteCurrencyBalances,
orders:
quoteExists && market
? market.quoteSplSizeToNumber(
openOrdersAccount.quoteTokenTotal.sub(
openOrdersAccount.quoteTokenFree,
),
)
: null,
unsettled:
quoteExists && market
? market.quoteSplSizeToNumber(openOrdersAccount.quoteTokenFree)
: null,
},
];
async function onSettleFunds() {
return await settleFunds({
market,
openOrders: openOrdersAccount,
connection,
wallet,
baseCurrencyAccount,
quoteCurrencyAccount,
});
}
const columns = [
{
title: 'Coin',
dataIndex: 'coin',
key: 'coin',
},
{
title: 'Wallet Balance',
dataIndex: 'wallet',
key: 'wallet',
},
{
title: 'Orders',
dataIndex: 'orders',
key: 'orders',
},
{
title: 'Unsettled',
dataIndex: 'unsettled',
key: 'unsettled',
},
{
key: 'action',
render: () => (
<div style={{ textAlign: 'right' }}>
<Button ghost style={{ marginRight: 12 }} onClick={onSettleFunds}>
Settle
</Button>
</div>
),
},
];
return (
<DataTable
emptyLabel="No balances"
dataSource={dataSource}
columns={columns}
pagination={false}
/>
);
}

View File

@ -0,0 +1,73 @@
import React from 'react';
import { Row, Col, Tag } from 'antd';
import { useFills, useMarket } from '../../utils/markets';
import DataTable from '../layout/DataTable';
export default function FillsTable() {
const fills = useFills();
const { quoteCurrency } = useMarket();
const columns = [
{
title: 'Market',
dataIndex: 'marketName',
key: 'marketName',
},
{
title: 'Side',
dataIndex: 'side',
key: 'side',
render: (side) => (
<Tag
color={side === 'buy' ? '#41C77A' : '#F23B69'}
style={{ fontWeight: 700 }}
>
{side.charAt(0).toUpperCase() + side.slice(1)}
</Tag>
),
},
{
title: `Size`,
dataIndex: 'size',
key: 'size',
},
{
title: `Price`,
dataIndex: 'price',
key: 'price',
},
{
title: `Liquidity`,
dataIndex: 'liquidity',
key: 'liquidity',
},
{
title: quoteCurrency ? `Fees (${quoteCurrency})` : 'Fees',
dataIndex: 'feeCost',
key: 'feeCost',
},
];
const dataSource = (fills || []).map((fill) => ({
...fill,
key: `${fill.orderId}${fill.side}`,
liquidity: fill.eventFlags.maker ? 'Maker' : 'Taker',
}));
return (
<>
<Row>
<Col span={24}>
<DataTable
dataSource={dataSource}
columns={columns}
pagination={true}
pageSize={5}
emptyLabel="No fills"
/>
</Col>
</Row>
</>
);
}

View File

@ -0,0 +1,108 @@
import React, { useState } from 'react';
import { useMarket, useOpenOrders } from '../../utils/markets';
import DataTable from '../layout/DataTable';
import styled from 'styled-components';
import { Button, Row, Col, Tag } from 'antd';
import { cancelOrder } from '../../utils/send';
import { useWallet } from '../../utils/wallet';
import { useConnection } from '../../utils/connection';
import { notify } from '../../utils/notifications';
import { DeleteOutlined } from '@ant-design/icons';
const CancelButton = styled(Button)`
color: #f23b69;
border: 1px solid #f23b69;
`;
export default function OpenOrderTable() {
let { market } = useMarket();
let [, wallet] = useWallet();
let connection = useConnection();
const [cancelId, setCancelId] = useState(null);
const openOrders = useOpenOrders();
async function cancel(order) {
setCancelId(order?.orderId);
try {
await cancelOrder({
order,
market,
connection,
wallet,
callback: () => setCancelId(null),
});
} catch (e) {
notify({
message: 'Error cancelling order: ' + e.message,
type: 'error',
});
setCancelId(null);
}
}
const columns = [
{
title: 'Market',
dataIndex: 'marketName',
key: 'marketName',
},
{
title: 'Side',
dataIndex: 'side',
key: 'side',
render: (side) => (
<Tag
color={side === 'buy' ? '#41C77A' : '#F23B69'}
style={{ fontWeight: 700 }}
>
{side.charAt(0).toUpperCase() + side.slice(1)}
</Tag>
),
},
{
title: 'Size',
dataIndex: 'size',
key: 'size',
},
{
title: 'Price',
dataIndex: 'price',
key: 'price',
},
{
key: 'orderId',
render: (order) => (
<div style={{ textAlign: 'right' }}>
<CancelButton
icon={<DeleteOutlined />}
onClick={() => cancel(order)}
loading={cancelId === order.orderId}
>
Cancel
</CancelButton>
</div>
),
},
];
let orders = openOrders;
const dataSource = (orders || []).map((order) =>
Object.assign(order, { key: order.orderId }),
);
return (
<Row>
<Col span={24}>
<DataTable
emptyLabel="No open orders"
dataSource={dataSource}
columns={columns}
pagination={true}
pageSize={5}
/>
</Col>
</Row>
);
}

View File

@ -0,0 +1,26 @@
import BalancesTable from './BalancesTable';
import OpenOrderTable from './OpenOrderTable';
import React from 'react';
import { Tabs } from 'antd';
import FillsTable from './FillsTable';
import FloatingElement from '../layout/FloatingElement';
const { TabPane } = Tabs;
export default function Index() {
return (
<FloatingElement style={{ flex: 1, paddingTop: 10 }}>
<Tabs defaultActiveKey="orders">
<TabPane tab="Open Orders" key="orders">
<OpenOrderTable />
</TabPane>
<TabPane tab="Trade History" key="fills">
<FillsTable />
</TabPane>
<TabPane tab="Balances" key="balances">
<BalancesTable />
</TabPane>
</Tabs>
</FloatingElement>
);
}

View File

@ -0,0 +1,33 @@
import React from 'react';
import { ConfigProvider, Table } from 'antd';
export default function DataTable({
dataSource,
columns,
emptyLabel = 'No data',
pagination = false,
pageSize = 10,
}) {
const customizeRenderEmpty = () => (
<div
style={{
padding: '20px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
{emptyLabel}
</div>
);
return (
<ConfigProvider renderEmpty={customizeRenderEmpty}>
<Table
dataSource={dataSource}
columns={columns}
pagination={pagination ? { pagination: true, pageSize } : false}
/>
</ConfigProvider>
);
}

View File

@ -0,0 +1,12 @@
import React from 'react';
import styled from 'styled-components';
const Wrapper = styled.div`
margin: 5px;
padding: 20px;
background-color: #1a2029;
`;
export default function FloatingElement({ style, children }) {
return <Wrapper style={{ ...style }}>{children}</Wrapper>;
}

92
src/global_style.ts Normal file
View File

@ -0,0 +1,92 @@
import { createGlobalStyle } from 'styled-components';
export const GlobalStyle = createGlobalStyle`
html,body{
background: #11161D;
min-width: 770px;
}
input[type=number]::-webkit-inner-spin-button {
opacity: 0;
}
input[type=number]:hover::-webkit-inner-spin-button,
input[type=number]:focus::-webkit-inner-spin-button {
opacity: 0.25;
}
/* width */
::-webkit-scrollbar {
width: 15px;
}
/* Track */
::-webkit-scrollbar-track {
background: #2d313c;
}
/* Handle */
::-webkit-scrollbar-thumb {
background: #5b5f67;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: #5b5f67;
}
.ant-slider-track, .ant-slider:hover .ant-slider-track {
background-color: #2abdd2;
opacity: 0.75;
}
.ant-slider-track,
.ant-slider ant-slider-track:hover {
background-color: #2abdd2;
opacity: 0.75;
}
.ant-slider-dot-active,
.ant-slider-handle,
.ant-slider-handle-click-focused,
.ant-slider:hover .ant-slider-handle:not(.ant-tooltip-open) {
border: 2px solid #2abdd2;
}
.ant-table-tbody > tr.ant-table-row:hover > td {
background: #273043;
}
.ant-table-tbody > tr > td {
border-bottom: 8px solid #1A2029;
}
.ant-table-container table > thead > tr:first-child th {
border-bottom: none;
}
.ant-divider-horizontal.ant-divider-with-text::before, .ant-divider-horizontal.ant-divider-with-text::after {
border-top: 1px solid #434a59 !important;
}
.ant-layout {
background: #11161D
}
.ant-table {
background: #212734;
}
.ant-table-thead > tr > th {
background: #1A2029;
}
.ant-select-item-option-content {
img {
margin-right: 4px;
}
}
.ant-modal-content {
background-color: #212734;
}
@-webkit-keyframes highlight {
from { background-color: #2abdd2;}
to {background-color: #1A2029;}
}
@-moz-keyframes highlight {
from { background-color: #2abdd2;}
to {background-color: #1A2029;}
}
@-keyframes highlight {
from { background-color: #2abdd2;}
to {background-color: #1A2029;}
}
.flash {
-moz-animation: highlight 0.5s ease 0s 1 alternate ;
-webkit-animation: highlight 0.5s ease 0s 1 alternate;
animation: highlight 0.5s ease 0s 1 alternate;
}`;

13
src/index.css Normal file
View File

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

17
src/index.js Normal file
View File

@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root'),
);
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

7
src/logo.svg Normal file
View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

211
src/pages/TradePage.jsx Normal file
View File

@ -0,0 +1,211 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Button, Col, Row, Select } from 'antd';
import styled from 'styled-components';
import Orderbook from '../components/Orderbook';
import UserInfoTable from '../components/UserInfoTable';
import React from 'react';
import StandaloneBalancesDisplay from '../components/StandaloneBalancesDisplay';
import { useMarket, useMarketsList } from '../utils/markets';
import TradeForm from '../components/TradeForm';
import { Input } from 'antd';
import { useLocalStorageState } from '../utils/utils';
import TradesTable from '../components/TradesTable';
const { Option } = Select;
const Wrapper = styled.div`
height: 100%;
display: flex;
flex-direction: column;
padding: 16px 16px;
.borderNone .ant-select-selector {
border: none !important;
}
`;
function hashString(s) {
var h = 0,
l = s.length,
i = 0;
if (l > 0) while (i < l) h = ((h << 5) - h + s.charCodeAt(i++)) | 0;
return h;
}
const requirePassword = false;
export default function TradePage() {
const { marketName, setMarketName } = useMarket();
const markets = useMarketsList();
const [submittedPassword, setSubmittedPassword] = useLocalStorageState(
'submittedPassword5',
false,
);
const [password, setPassword] = useState('password');
const [dimensions, setDimensions] = useState({
height: window.innerHeight,
width: window.innerWidth,
});
const changeOrderRef = useRef();
useEffect(() => {
const handleResize = () => {
setDimensions({
height: window.innerHeight,
width: window.innerWidth,
});
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const width = dimensions?.width;
const componentProps = {
onChangeOrderRef: (ref) => (changeOrderRef.current = ref),
onPrice: (price) =>
changeOrderRef.current && changeOrderRef.current({ price }),
onSize: (size) =>
changeOrderRef.current && changeOrderRef.current({ size }),
};
const getComponent = useCallback(() => {
if (width < 1000) {
return <RenderSmaller {...componentProps} />;
} else if (width < 1450) {
return <RenderSmall {...componentProps} />;
} else {
return <RenderNormal {...componentProps} />;
}
}, [width, componentProps]);
if (!submittedPassword && requirePassword) {
return (
<Wrapper style={{ width: 400 }}>
<Input.Password
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button
onClick={() => {
if (hashString(password) === 99071593) {
setSubmittedPassword(true);
}
}}
block
type="primary"
size="large"
>
Submit
</Button>
</Wrapper>
);
}
return (
<Wrapper>
<Row>
<Col>
<Select bordered={false} onSelect={setMarketName} value={marketName}>
{markets.map(({ name, address }) => (
<Option value={name} key={address}>
{name}
</Option>
))}
</Select>
</Col>
</Row>
{getComponent()}
</Wrapper>
);
}
const RenderNormal = ({ onChangeOrderRef, onPrice, onSize }) => {
return (
<Row
style={{
minHeight: '750px',
}}
>
<Col flex="auto" style={{ height: '100%', display: 'flex' }}>
<UserInfoTable />
</Col>
<Col flex={'360px'} style={{ height: '100%' }}>
<Orderbook smallScreen={false} onPrice={onPrice} onSize={onSize} />
<TradesTable smallScreen={false} />
</Col>
<Col
flex="400px"
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
>
<TradeForm setChangeOrderRef={onChangeOrderRef} />
<StandaloneBalancesDisplay />
</Col>
</Row>
);
};
const RenderSmall = ({ onChangeOrderRef, onPrice, onSize }) => {
return (
<>
<Row
style={{
height: '750px',
}}
>
<Col flex="auto" style={{ height: '100%', display: 'flex' }}>
<Orderbook
smallScreen={true}
depth={11}
onPrice={onPrice}
onSize={onSize}
/>
</Col>
<Col flex="auto" style={{ height: '100%', display: 'flex' }}>
<TradesTable smallScreen={true} />
</Col>
<Col
flex="400px"
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
>
<TradeForm setChangeOrderRef={onChangeOrderRef} />
<StandaloneBalancesDisplay />
</Col>
</Row>
<Row>
<Col flex="auto">
<UserInfoTable />
</Col>
</Row>
</>
);
};
const RenderSmaller = ({ onChangeOrderRef, onPrice, onSize }) => {
return (
<>
<Row>
<Col span={12} style={{ height: '100%', display: 'flex' }}>
<TradeForm style={{ flex: 1 }} setChangeOrderRef={onChangeOrderRef} />
</Col>
<Col span={12}>
<StandaloneBalancesDisplay />
</Col>
</Row>
<Row style={{ minHeight: '500px' }}>
<Col span={12} style={{ height: '100%', display: 'flex' }}>
<Orderbook smallScreen={true} onPrice={onPrice} onSize={onSize} />
</Col>
<Col span={12} style={{ height: '100%', display: 'flex' }}>
<TradesTable smallScreen={true} />
</Col>
</Row>
<Row>
<Col flex="auto">
<UserInfoTable />
</Col>
</Row>
</>
);
};

1
src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

141
src/serviceWorker.js Normal file
View File

@ -0,0 +1,141 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/,
),
);
export function register(config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA',
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.',
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch((error) => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl, config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' },
})
.then((response) => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.',
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then((registration) => {
registration.unregister();
})
.catch((error) => {
console.error(error.message);
});
}
}

5
src/setupTests.js Normal file
View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom/extend-expect';

92
src/utils/connection.js Normal file
View File

@ -0,0 +1,92 @@
import { useLocalStorageState } from './utils';
import { clusterApiUrl, Connection } from '@solana/web3.js';
import React, { useContext, useMemo, useEffect } from 'react';
import { setCache, useAsyncData } from './fetch-loop';
import tuple from 'immutable-tuple';
export const ENDPOINTS = [
{
name: 'mainnet-beta',
endpoint: clusterApiUrl('mainnet-beta'),
},
{ name: 'testnet', endpoint: clusterApiUrl('testnet') },
{ name: 'devnet', endpoint: clusterApiUrl('devnet') },
{ name: 'localnet', endpoint: 'http://127.0.0.1:8899' },
];
const ConnectionContext = React.createContext(null);
export function ConnectionProvider({ children }) {
const [endpoint, setEndpoint] = useLocalStorageState(
'connectionEndpts',
clusterApiUrl('mainnet-beta'),
);
const connection = useMemo(() => new Connection(endpoint, 'recent'), [
endpoint,
]);
// The websocket library solana/web3.js uses closes its websocket connection when the subscription list
// is empty after opening its first time, preventing subsequent subscriptions from receiving responses.
// This is a hack to prevent the list from every getting empty
useEffect(() => {
const id = connection.onSignature(
'do not worry, this is expected to yield warning logs',
(result) => {
console.log(
'Received onSignature responses from does-not-exist',
result,
);
},
);
return () => connection.removeSignatureListener(id);
}, [connection]);
return (
<ConnectionContext.Provider value={{ endpoint, setEndpoint, connection }}>
{children}
</ConnectionContext.Provider>
);
}
export function useConnection() {
return useContext(ConnectionContext).connection;
}
export function useConnectionConfig() {
const context = useContext(ConnectionContext);
return { endpoint: context.endpoint, setEndpoint: context.setEndpoint };
}
export function useAccountInfo(publicKey) {
const connection = useConnection();
const cacheKey = tuple(connection, publicKey?.toBase58());
const [accountInfo, loaded] = useAsyncData(
async () => (publicKey ? connection.getAccountInfo(publicKey) : null),
cacheKey,
{ refreshInterval: 60000000 },
);
useEffect(() => {
if (!publicKey) {
return () => {};
}
let previousData = null;
// TODO: Just pipe the websocket data instead of re-fetching over REST
const id = connection.onAccountChange(publicKey, (e) => {
if (e.data) {
if (previousData && !previousData.equals(e.data)) {
setCache(cacheKey, e);
}
previousData = e.data;
}
});
return () => connection.removeAccountChangeListener(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connection, publicKey?.toBase58(), cacheKey]);
return [accountInfo, loaded];
}
export function useAccountData(publicKey) {
const [accountInfo] = useAccountInfo(publicKey);
return accountInfo && accountInfo.data;
}

201
src/utils/fetch-loop.js Normal file
View File

@ -0,0 +1,201 @@
import { useEffect, useReducer } from 'react';
import assert from 'assert';
const pageLoadTime = new Date();
const globalCache = new Map();
class FetchLoopListener {
constructor(cacheKey, fn, refreshInterval, callback) {
this.cacheKey = cacheKey;
this.fn = fn;
this.refreshInterval = refreshInterval;
this.callback = callback;
}
}
class FetchLoopInternal {
constructor(cacheKey, fn) {
this.cacheKey = cacheKey;
this.fn = fn;
this.timeoutId = null;
this.listeners = new Set();
this.errors = 0;
}
get refreshInterval() {
return Math.min(
...[...this.listeners].map((listener) => listener.refreshInterval),
);
}
get stopped() {
return this.listeners.size === 0;
}
addListener(listener) {
const previousRefreshInterval = this.refreshInterval;
this.listeners.add(listener);
if (this.refreshInterval < previousRefreshInterval) {
this.refresh();
}
}
removeListener(listener) {
assert(this.listeners.delete(listener));
if (this.stopped) {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
}
}
notifyListeners() {
this.listeners.forEach((listener) => listener.callback());
}
refresh = async () => {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
if (this.stopped) {
return;
}
try {
const data = await this.fn();
globalCache.set(this.cacheKey, data);
this.errors = 0;
this.notifyListeners();
return data;
} catch (error) {
++this.errors;
console.warn(error);
} finally {
if (!this.timeoutId && !this.stopped) {
let waitTime = this.refreshInterval;
// Back off on errors.
if (this.errors > 0) {
waitTime = Math.min(1000 * 2 ** (this.errors - 1), 60000);
}
// Don't do any refreshing for the first five seconds, to make way for other things to load.
const timeSincePageLoad = new Date() - pageLoadTime;
if (timeSincePageLoad < 5000) {
waitTime += 5000 - timeSincePageLoad / 2;
}
// Refresh background pages slowly.
if (document.visibilityState === 'hidden') {
waitTime = 60000;
} else if (!document.hasFocus()) {
waitTime *= 1.5;
}
// Add jitter so we don't send all requests at the same time.
waitTime *= 0.8 + 0.4 * Math.random();
this.timeoutId = setTimeout(this.refresh, waitTime);
}
}
};
}
class FetchLoops {
loops = new Map();
addListener(listener) {
if (!this.loops.has(listener.cacheKey)) {
this.loops.set(
listener.cacheKey,
new FetchLoopInternal(listener.cacheKey, listener.fn),
);
}
this.loops.get(listener.cacheKey).addListener(listener);
}
removeListener(listener) {
const loop = this.loops.get(listener.cacheKey);
loop.removeListener(listener);
if (loop.stopped) {
this.loops.delete(listener.cacheKey);
}
}
refresh(cacheKey) {
if (this.loops.has(cacheKey)) {
this.loops.get(cacheKey).refresh();
}
}
refreshAll() {
return Promise.all([...this.loops.values()].map((loop) => loop.refresh()));
}
}
const globalLoops = new FetchLoops();
export function useAsyncData(
asyncFn,
cacheKey,
{ refreshInterval = 60000 } = {},
) {
const [, rerender] = useReducer((i) => i + 1, 0);
useEffect(() => {
if (!cacheKey) {
// eslint-disable-next-line @typescript-eslint/no-empty-function
return () => {};
}
const listener = new FetchLoopListener(
cacheKey,
asyncFn,
refreshInterval,
rerender,
);
globalLoops.addListener(listener);
return () => globalLoops.removeListener(listener);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cacheKey, refreshInterval]);
if (!cacheKey) {
return [null, false];
}
const loaded = globalCache.has(cacheKey);
const data = loaded ? globalCache.get(cacheKey) : undefined;
return [data, loaded];
}
export function refreshCache(cacheKey, clearCache = false) {
if (clearCache) {
globalCache.delete(cacheKey);
}
const loop = globalLoops.loops.get(cacheKey);
if (loop) {
loop.refresh();
if (clearCache) {
loop.notifyListeners();
}
}
}
export function refreshAllCaches() {
for (const loop of globalLoops.loops.values()) {
loop.refresh();
}
}
export function setCache(cacheKey, value, { initializeOnly = false } = {}) {
if (initializeOnly && globalCache.has(cacheKey)) {
return;
}
globalCache.set(cacheKey, value);
const loop = globalLoops.loops.get(cacheKey);
if (loop) {
loop.notifyListeners();
}
}

510
src/utils/markets.js Normal file
View File

@ -0,0 +1,510 @@
import {
Market,
Orderbook,
decodeEventQueue,
DEX_PROGRAM_ID,
} from '@project-serum/serum';
import React, { useContext, useEffect, useState } from 'react';
import { PublicKey } from '@solana/web3.js';
import { useLocalStorageState } from './utils';
import { useAsyncData } from './fetch-loop';
import { useAccountData, useConnection } from './connection';
import { useWallet } from './wallet';
import tuple from 'immutable-tuple';
import { notify } from './notifications';
const DEFAULT_MARKET_NAME = 'BASE/QUOTE';
export const COIN_MINTS = {
EQePeYJUV9dQd5PWrPzgWMYvqaok8R8s5Cpa16VDPZxw: 'BASE',
Ff7neGEVckMNcvhnazvWZ41TJoNmwXS4xz1XDNd46s22: 'QUOTE',
};
export const MARKET_INFO_BY_NAME = {
'BASE/QUOTE': {
name: 'BASE/QUOTE',
address: '6ibUz1BqSD3f8XP4wEGwoRH4YbYRZ1KDZBeXmrp3KosD',
},
};
export function useMarketsList() {
return Object.values(MARKET_INFO_BY_NAME);
}
export function useAllMarkets() {
const [selectedDexProgramID] = useLocalStorageState(
'selectedDexProgramID',
DEX_PROGRAM_ID.toBase58(),
);
const connection = useConnection();
const [markets, setMarkets] = useState([]);
useEffect(() => {
const getAllMarkets = async () => {
const markets = [];
const marketList = Object.values(MARKET_INFO_BY_NAME);
let marketInfo;
for (marketInfo of marketList) {
const market = await Market.load(
connection,
new PublicKey(marketInfo.address),
{},
new PublicKey(selectedDexProgramID),
);
markets.push({ market, marketName: marketInfo.name });
}
setMarkets(markets);
};
getAllMarkets();
}, [connection, selectedDexProgramID]);
return markets;
}
const MarketContext = React.createContext(null);
// For things that don't really change
const _SLOW_REFRESH_INTERVAL = 1000 * 1000;
// For things that change frequently
const _FAST_REFRESH_INTERVAL = 5 * 1000;
export function MarketProvider({ children }) {
const [marketName, setMarketName] = useLocalStorageState(
'marketName',
DEFAULT_MARKET_NAME,
);
const [selectedDexProgramID, setSelectedDexProgramID] = useLocalStorageState(
'selectedDexProgramID',
DEX_PROGRAM_ID.toBase58(),
);
const connection = useConnection();
const marketInfo = MARKET_INFO_BY_NAME[marketName];
const [market, setMarket] = useState();
useEffect(() => {
setMarket(null);
if (!marketInfo || !marketInfo.address) {
notify({
message: 'Error loading market',
description: 'Please select a market from the dropdown',
type: 'error',
});
return;
}
Market.load(
connection,
new PublicKey(marketInfo.address),
{},
new PublicKey(selectedDexProgramID),
)
.then(setMarket)
.catch((e) =>
notify({
message: 'Error loading market',
description: e.message,
type: 'error',
}),
);
}, [connection, marketName, marketInfo, selectedDexProgramID]);
const baseCurrency =
COIN_MINTS[market?.baseMintAddress?.toBase58()] || 'UNKNOWN';
const quoteCurrency =
COIN_MINTS[market?.quoteMintAddress?.toBase58()] || 'UNKNOWN';
return (
<MarketContext.Provider
value={{
market,
marketName,
setMarketName,
...marketInfo,
baseCurrency,
quoteCurrency,
selectedDexProgramID,
setSelectedDexProgramID,
}}
>
{children}
</MarketContext.Provider>
);
}
export function useMarket() {
return useContext(MarketContext);
}
export function useMarkPrice() {
const [markPrice, setMarkPrice] = useState(null);
const [orderbook] = useOrderbook();
const trades = useTrades();
useEffect(() => {
let bb = orderbook?.bids?.length > 0 && Number(orderbook.bids[0][0]);
let ba = orderbook?.asks?.length > 0 && Number(orderbook.asks[0][0]);
let last = trades?.length > 0 && trades[0].price;
let markPrice =
bb && ba
? last
? [bb, ba, last].sort((a, b) => a - b)[1]
: (bb + ba) / 2
: null;
setMarkPrice(markPrice);
}, [orderbook, trades]);
return markPrice;
}
export function _useUnfilteredTrades(limit = 100000) {
const { market } = useMarket();
let data = useAccountData(market && market._decoded.eventQueue);
if (!data) {
return null;
}
const events = decodeEventQueue(data, limit);
return events
.filter((event) => event.eventFlags.fill && event.nativeQuantityPaid.gtn(0))
.map(market.parseFillEvent.bind(market));
}
export function useOrderbookAccounts() {
const { market } = useMarket();
let bidData = useAccountData(market && market._decoded.bids);
let askData = useAccountData(market && market._decoded.asks);
return {
bidOrderbook: bidData ? Orderbook.decode(market, bidData) : null,
askOrderbook: askData ? Orderbook.decode(market, askData) : null,
};
}
export function useOrderbook(depth = 20) {
const { bidOrderbook, askOrderbook } = useOrderbookAccounts();
const { market } = useMarket();
const bids =
!bidOrderbook || !market
? []
: bidOrderbook
.getL2(depth)
.map(([price, size]) => [price.toFixed(2), size]);
const asks =
!askOrderbook || !market
? []
: askOrderbook
.getL2(depth)
.map(([price, size]) => [price.toFixed(2), size]);
return [{ bids, asks }, !!bids || !!asks];
}
// Want the balances table to be fast-updating, dont want open orders to flicker
// TODO: Update to use websocket
export function useOpenOrdersAccounts(fast = false) {
const { market } = useMarket();
const [connected, wallet] = useWallet();
const connection = useConnection();
async function getTokenAccounts() {
if (!connected) {
return null;
}
if (!market) {
return null;
}
return await market.findOpenOrdersAccountsForOwner(
connection,
wallet.publicKey,
);
}
return useAsyncData(
getTokenAccounts,
tuple('openOrdersAccounts', wallet, market, connected),
{ refreshInterval: fast ? _FAST_REFRESH_INTERVAL : _SLOW_REFRESH_INTERVAL },
);
}
export function useSelectedOpenOrdersAccount(fast = false) {
const [accounts] = useOpenOrdersAccounts(fast);
if (!accounts) {
return null;
}
return accounts[0];
}
export function useOpenOrdersAddresses() {
const [openOrdersAccounts] = useOpenOrdersAccounts();
if (!openOrdersAccounts) {
return null;
}
return openOrdersAccounts.map(({ publicKey }) => publicKey);
}
// This is okay to poll
export function useBaseCurrencyAccounts() {
const { market } = useMarket();
const [connected, wallet] = useWallet();
const connection = useConnection();
async function getTokenAccounts() {
if (!connected) {
return null;
}
if (!market) {
return null;
}
return await market.findBaseTokenAccountsForOwner(
connection,
wallet.publicKey,
);
}
return useAsyncData(
getTokenAccounts,
tuple('getTokenAccounts', wallet, market, connected),
{ refreshInterval: _SLOW_REFRESH_INTERVAL },
);
}
// This is okay to poll
export function useQuoteCurrencyAccounts() {
const { market } = useMarket();
const [connected, wallet] = useWallet();
const connection = useConnection();
async function getTokenAccounts() {
if (!connected) {
return null;
}
if (!market) {
return null;
}
return await market.findQuoteTokenAccountsForOwner(
connection,
wallet.publicKey,
);
}
return useAsyncData(
getTokenAccounts,
tuple('useQuoteCurrencyAccounts', wallet, market, connected),
{ refreshInterval: _SLOW_REFRESH_INTERVAL },
);
}
export function useSelectedQuoteCurrencyAccount() {
const [accounts] = useQuoteCurrencyAccounts();
if (!accounts) {
return null;
}
return accounts[0];
}
export function useSelectedBaseCurrencyAccount() {
const [accounts] = useBaseCurrencyAccounts();
if (!accounts) {
return null;
}
return accounts[0];
}
// TODO: Update to use websocket
export function useQuoteCurrencyBalances() {
const connection = useConnection();
const quoteCurrencyAccount = useSelectedQuoteCurrencyAccount();
async function getBalance() {
if (!quoteCurrencyAccount) {
return null;
}
const balances = await connection.getTokenAccountBalance(
quoteCurrencyAccount.pubkey,
);
return balances && balances.value && balances.value.uiAmount;
}
return useAsyncData(
getBalance,
tuple(
'useQuoteCurrencyBalances',
connection,
quoteCurrencyAccount && quoteCurrencyAccount.pubkey.toBase58(),
),
{ refreshInterval: _FAST_REFRESH_INTERVAL },
);
}
// TODO: Update to use websocket
export function useBaseCurrencyBalances() {
const connection = useConnection();
const baseCurrencyAccount = useSelectedBaseCurrencyAccount();
async function getBalance() {
if (!baseCurrencyAccount) {
return null;
}
const balances = await connection.getTokenAccountBalance(
baseCurrencyAccount.pubkey,
);
return balances && balances.value && balances.value.uiAmount;
}
return useAsyncData(
getBalance,
tuple(
'useBaseCurrencyBalances',
connection,
baseCurrencyAccount && baseCurrencyAccount.pubkey.toBase58(),
),
{ refreshInterval: _FAST_REFRESH_INTERVAL },
);
}
export function useOpenOrders() {
const { market, marketName } = useMarket();
const openOrdersAccount = useSelectedOpenOrdersAccount();
const { bidOrderbook, askOrderbook } = useOrderbookAccounts();
if (!market || !openOrdersAccount || !bidOrderbook || !askOrderbook) {
return null;
}
return market
.filterForOpenOrders(bidOrderbook, askOrderbook, [openOrdersAccount])
.map((order) => ({ ...order, marketName }));
}
export function useTrades(limit = 100) {
const trades = _useUnfilteredTrades(limit);
if (!trades) {
return null;
}
// Until partial fills are each given their own fill, use maker fills
return trades
.filter(({ eventFlags }) => eventFlags.maker)
.map((trade) => ({
...trade,
side: trade.side === 'buy' ? 'sell' : 'buy',
}));
}
export function useFills(limit = 100) {
const { marketName } = useMarket();
const fills = _useUnfilteredTrades(limit);
const [openOrdersAccounts] = useOpenOrdersAccounts();
if (!openOrdersAccounts || openOrdersAccounts.length === 0) {
return null;
}
if (!fills) {
return null;
}
const openOrdersAccount = openOrdersAccounts[0];
return fills
.filter((fill) => fill.openOrders.equals(openOrdersAccount.publicKey))
.map((fill) => ({ ...fill, marketName }));
}
// TODO: Update to use websocket
export function useFillsForAllMarkets(limit = 100) {
const [connected, wallet] = useWallet();
const connection = useConnection();
const allMarkets = useAllMarkets();
async function getFillsForAllMarkets() {
let fills = [];
if (!connected) {
return fills;
}
let marketData;
for (marketData of allMarkets) {
const { market, marketName } = marketData;
if (!market) {
return fills;
}
const openOrdersAccounts = await market.findOpenOrdersAccountsForOwner(
connection,
wallet.publicKey,
);
const openOrdersAccount = openOrdersAccounts && openOrdersAccounts[0];
if (!openOrdersAccount) {
return fills;
}
const eventQueueData = await connection.getAccountInfo(
market && market._decoded.eventQueue,
);
let data = eventQueueData?.data;
if (!data) {
return fills;
}
const events = decodeEventQueue(data, limit);
const fillsForMarket = events
.filter(
(event) => event.eventFlags.fill && event.nativeQuantityPaid.gtn(0),
)
.map(market.parseFillEvent.bind(market));
const ownFillsForMarket = fillsForMarket
.filter((fill) => fill.openOrders.equals(openOrdersAccount.publicKey))
.map((fill) => ({ ...fill, marketName }));
fills = fills.concat(ownFillsForMarket);
}
console.log(JSON.stringify(fills));
return fills;
}
return useAsyncData(
getFillsForAllMarkets,
tuple('getFillsForAllMarkets', connected, connection, allMarkets, wallet),
{ refreshInterval: _FAST_REFRESH_INTERVAL },
);
}
// TODO: Update to use websocket
export function useOpenOrdersForAllMarkets() {
const [connected, wallet] = useWallet();
const connection = useConnection();
const allMarkets = useAllMarkets();
async function getOpenOrdersForAllMarkets() {
let orders = [];
if (!connected) {
return orders;
}
let marketData;
for (marketData of allMarkets) {
const { market, marketName } = marketData;
if (!market) {
return orders;
}
const openOrdersAccounts = await market.findOpenOrdersAccountsForOwner(
connection,
wallet.publicKey,
);
const openOrdersAccount = openOrdersAccounts && openOrdersAccounts[0];
if (!openOrdersAccount) {
return orders;
}
const [bids, asks] = await Promise.all([
market.loadBids(connection),
market.loadAsks(connection),
]);
const ordersForMarket = [...bids, ...asks]
.filter((order) => {
return order.openOrdersAddress.equals(openOrdersAccount.publicKey);
})
.map((order) => {
return { ...order, marketName };
});
orders = orders.concat(ordersForMarket);
}
return orders;
}
return useAsyncData(
getOpenOrdersForAllMarkets,
tuple(
'getOpenOrdersForAllMarkets',
connected,
connection,
wallet,
allMarkets,
),
{ refreshInterval: _SLOW_REFRESH_INTERVAL },
);
}

View File

@ -0,0 +1,20 @@
import React from 'react';
import { notification } from 'antd';
export function notify({
message,
description,
type = 'info',
placement = 'bottomLeft',
}) {
notification[type]({
message: <span style={{ color: 'black' }}>{message}</span>,
description: (
<span style={{ color: 'black', opacity: 0.5 }}>{description}</span>
),
placement,
style: {
backgroundColor: 'white',
},
});
}

313
src/utils/send.js Normal file
View File

@ -0,0 +1,313 @@
import { notify } from './notifications';
import nacl from 'tweetnacl';
import { sleep } from './utils';
import { Transaction } from '@solana/web3.js';
export async function settleFunds({
market,
openOrders,
connection,
wallet,
baseCurrencyAccount,
quoteCurrencyAccount,
}) {
if (
!market ||
!wallet ||
!connection ||
!openOrders ||
!baseCurrencyAccount ||
!quoteCurrencyAccount
) {
notify({ message: 'Not connected' });
return;
}
const transaction = await market.makeSettleFundsTransaction(
connection,
openOrders,
baseCurrencyAccount.pubkey,
quoteCurrencyAccount.pubkey,
);
const onConfirm = (result) => {
if (result.err) {
console.log(result.err);
notify({ message: 'Error settling funds', type: 'error' });
} else {
notify({ message: 'Fund settlement confirmed', type: 'success' });
}
};
const onBeforeSend = () => notify({ message: 'Settling funds...' });
const onAfterSend = () =>
notify({ message: 'Funds settled', type: 'success' });
return await sendTransaction({
transaction,
wallet,
connection,
onBeforeSend,
onAfterSend,
onConfirm,
});
}
export async function cancelOrder(params) {
return cancelOrders({ ...params, orders: [params.order] });
}
export async function cancelOrders({
market,
wallet,
connection,
orders,
callback,
}) {
const transaction = new Transaction();
transaction.add(market.makeMatchOrdersInstruction(5));
orders.forEach((order) => {
transaction.add(
market.makeCancelOrderInstruction(connection, wallet.publicKey, order),
);
});
transaction.add(market.makeMatchOrdersInstruction(5));
const onConfirm = (result) => {
if (result.err) {
console.log(result.err);
notify({
message:
orders.length > 1
? 'Error cancelling orders'
: 'Error cancelling order',
type: 'error',
});
} else {
notify({
message:
orders.length > 1
? 'Order cancellations confirmed'
: 'Order cancellation confirmed',
type: 'success',
});
}
callback && callback();
};
const onBeforeSend = () => notify({ message: 'Sending cancel...' });
const onAfterSend = () =>
notify({
message: orders.length > 1 ? 'Orders cancelled' : 'Order cancelled',
type: 'success',
});
return await sendTransaction({
transaction,
wallet,
connection,
onBeforeSend,
onAfterSend,
onConfirm,
});
}
export async function placeOrder({
side,
price,
size,
orderType,
market,
connection,
wallet,
callback,
baseCurrencyAccount,
quoteCurrencyAccount,
openOrdersAccount,
}) {
const isIncrement = (num, step) =>
Math.abs((num / step) % 1) < 1e-10 ||
Math.abs(((num / step) % 1) - 1) < 1e-10;
if (isNaN(price)) {
notify({ message: 'Invalid price', type: 'error' });
return;
}
if (isNaN(size)) {
notify({ message: 'Invalid size', type: 'error' });
return;
}
if (!wallet || !wallet.publicKey) {
notify({ message: 'Connect wallet', type: 'error' });
return;
}
if (!market) {
notify({ message: 'Invalid market', type: 'error' });
return;
}
if (!isIncrement(size, market.minOrderSize)) {
notify({
message: `Size must be an increment of ${market.minOrderSize}`,
type: 'error',
});
return;
}
if (size < market.minOrderSize) {
notify({ message: 'Size too small', type: 'error' });
return;
}
if (!isIncrement(price, market.tickSize)) {
notify({
message: `Price must be an increment of ${market.tickSize}`,
type: 'error',
});
return;
}
if (price < market.tickSize) {
notify({ message: 'Price under tick size', type: 'error' });
return;
}
const owner = wallet.publicKey;
const payer = side === 'sell' ? baseCurrencyAccount : quoteCurrencyAccount;
if (!payer) {
notify({
message: 'Need an SPL token account for cost currency',
type: 'error',
});
return;
}
const params = {
owner,
payer,
side,
price,
size,
orderType,
};
let transaction, signers;
let extraSigners = [];
// If the user does not has an open orders account, use serum-js to create one
if (!openOrdersAccount) {
let result = await market.makePlaceOrderTransaction(connection, params);
transaction = result.transaction;
signers = result.signers;
if (signers.length > 1) {
extraSigners = [signers[1]];
}
} else {
transaction = new Transaction();
transaction.add(market.makeMatchOrdersInstruction(5));
transaction.add(
market.makePlaceOrderInstruction(connection, params, openOrdersAccount),
);
}
transaction.add(market.makeMatchOrdersInstruction(5));
const onConfirm = (result) => {
if (result.err) {
console.log(result.err);
notify({ message: 'Error placing order', type: 'error' });
} else {
notify({ message: 'Order confirmed', type: 'success' });
}
callback && callback();
};
const onBeforeSend = () => notify({ message: 'Sending order...' });
const onAfterSend = () => notify({ message: 'Order sent', type: 'success' });
return await sendTransaction({
transaction,
wallet,
connection,
onBeforeSend,
onAfterSend,
onConfirm,
extraSigners,
});
}
async function sendTransaction({
transaction,
wallet,
connection,
onBeforeSend,
onAfterSend,
onConfirm,
extraSigners = [],
}) {
transaction.recentBlockhash = (
await connection.getRecentBlockhash('max')
).blockhash;
const signed = await wallet.signTransaction(transaction);
const signedAt = new Date().getTime();
// Don't rely on the open orders account being the 2nd element in the list
// Sign with any accounts with a pubkey different from that of the wallet
extraSigners.forEach((extraSigner) => {
const extraSignature = nacl.sign.detached(
signed.serializeMessage(),
extraSigner.secretKey,
);
signed.addSignature(extraSigner.publicKey, extraSignature);
});
onBeforeSend();
const txid = await connection.sendRawTransaction(signed.serialize(), {
skipPreflight: true,
});
const sentAt = new Date().getTime();
onAfterSend();
// Send a bunch of requests, staggered, and stop sending the other ones, resolve when getting back the first
const result = await getSignatureStatus(connection, txid);
const confirmedAt = new Date().getTime();
console.log(
'Confirmed',
(confirmedAt - sentAt) / 1000,
(confirmedAt - signedAt) / 1000,
);
onConfirm(result);
return txid;
}
async function getSignatureStatus(connection, txid) {
let done = false;
const result = await new Promise((resolve, reject) => {
(async () => {
connection.onSignature(
txid,
(result, context) => {
if (!done) {
console.log('WS update for txid', txid, result);
resolve(result);
done = true;
}
},
'recent',
);
while (!done) {
// eslint-disable-next-line
(async () => {
try {
const results = await connection.getSignatureStatuses([txid]);
const result = results && results[0];
if (
!result ||
(!result.value?.confirmations && !result.value?.err)
) {
return;
}
if (!done) {
console.log('REST update for txid', txid, results);
done = true;
resolve(result);
}
} catch (e) {
if (!done) {
console.log('REST error for txid', txid, e);
done = true;
reject(e);
}
}
})();
await sleep(500);
}
})();
});
done = true;
return result;
}

15
src/utils/usePrevious.js Normal file
View File

@ -0,0 +1,15 @@
import { useEffect, useRef } from 'react';
export default function usePrevious(value) {
// The ref object is a generic container whose current property is mutable ...
// ... and can hold any value, similar to an instance property on a class
const ref = useRef();
// Store current value in ref
useEffect(() => {
ref.current = value;
}, [value]); // Only re-run if value changes
// Return previous value (happens before update in useEffect above)
return ref.current;
}

75
src/utils/utils.js Normal file
View File

@ -0,0 +1,75 @@
import { useCallback, useEffect, useState } from 'react';
export async function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function getDecimalCount(value) {
if (Math.floor(value) !== value)
return value.toString().split('.')[1].length || 0;
return 0;
}
export function useLocalStorageState(key, defaultState = null) {
const [state, setState] = useState(() => {
// NOTE: Not sure if this is ok
const storedState = localStorage.getItem(key);
if (storedState) {
return JSON.parse(storedState);
}
return defaultState;
});
const setLocalStorageState = useCallback(
(newState) => {
const changed = state !== newState;
if (!changed) {
return;
}
setState(newState);
if (newState === null) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, JSON.stringify(newState));
}
},
[state, key],
);
return [state, setLocalStorageState];
}
export function useEffectAfterTimeout(effect, timeout) {
useEffect(() => {
const handle = setTimeout(effect, timeout);
return () => clearTimeout(handle);
});
}
export function useListener(emitter, eventName) {
const [, forceUpdate] = useState(0);
useEffect(() => {
const listener = () => forceUpdate((i) => i + 1);
emitter.on(eventName, listener);
return () => emitter.removeListener(eventName, listener);
}, [emitter, eventName]);
}
export function abbreviateAddress(address) {
const base58 = address.toBase58();
return base58.slice(0, 4) + '…' + base58.slice(-4);
}
export function isEqual(obj1, obj2, keys) {
if (!keys && Object.keys(obj1).length !== Object.keys(obj2).length) {
return false;
}
keys = keys || Object.keys(obj1);
for (const k of keys) {
if (obj1[k] !== obj2[k]) {
// shallow comparison
return false;
}
}
return true;
}

80
src/utils/wallet.js Normal file
View File

@ -0,0 +1,80 @@
import React, { useContext, useEffect, useMemo, useState } from 'react';
import Wallet from '@project-serum/sol-wallet-adapter';
import { notify } from './notifications';
import { useConnectionConfig } from './connection';
import { useLocalStorageState } from './utils';
export const WALLET_PROVIDERS = [
{ name: 'sollet.io', url: 'https://www.sollet.io' },
];
const WalletContext = React.createContext(null);
export function WalletProvider({ children }) {
const { endpoint } = useConnectionConfig();
const [providerUrl, setProviderUrl] = useLocalStorageState(
'walletProvider',
'https://www.sollet.io',
);
const wallet = useMemo(() => new Wallet(providerUrl, endpoint), [
providerUrl,
endpoint,
]);
const [connected, setConnected] = useState(false);
useEffect(() => {
console.log('trying to connect');
wallet.on('connect', () => {
console.log('connected');
setConnected(true);
let walletPublicKey = wallet.publicKey.toBase58();
let keyToDisplay =
walletPublicKey.length > 20
? `${walletPublicKey.substring(0, 7)}.....${walletPublicKey.substring(
walletPublicKey.length - 7,
walletPublicKey.length,
)}`
: walletPublicKey;
notify({
message: 'Wallet update',
description: 'Connected to wallet ' + keyToDisplay,
});
});
wallet.on('disconnect', () => {
setConnected(false);
notify({
message: 'Wallet update',
description: 'Disconnected from wallet',
});
});
return () => {
wallet.disconnect();
setConnected(false);
};
}, [wallet]);
return (
<WalletContext.Provider
value={{
wallet,
connected,
providerUrl,
setProviderUrl,
providerName: WALLET_PROVIDERS.find(({ url }) => url === providerUrl)
?.name,
}}
>
{children}
</WalletContext.Provider>
);
}
export function useWallet() {
const context = useContext(WalletContext);
return [
context.connected,
context.wallet,
context.providerUrl,
context.setProviderUrl,
context.providerName,
];
}

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"extends": "@tsconfig/node12/tsconfig.json",
"compilerOptions": {
"outDir": "./lib",
"allowJs": true,
"checkJs": false,
"declaration": true,
"declarationMap": true,
"noImplicitAny": false,
"sourceMap": true,
"allowSyntheticDefaultImports": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react"
},
"include": [
"./src/"
],
"exclude": [
"./src/**/*.test.js",
"node_modules",
"**/node_modules"
]
}

12452
yarn.lock Normal file

File diff suppressed because it is too large Load Diff