Initial commit
|
@ -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
|
|
@ -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.
|
|
@ -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.
|
|
@ -0,0 +1,17 @@
|
||||||
|
const CracoLessPlugin = require('craco-less');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
plugin: CracoLessPlugin,
|
||||||
|
options: {
|
||||||
|
lessLoaderOptions: {
|
||||||
|
lessOptions: {
|
||||||
|
modifyVars: { '@primary-color': '#2abdd2' },
|
||||||
|
javascriptEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
demo.projectserum.com
|
After Width: | Height: | Size: 4.5 KiB |
After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 4.1 KiB |
After Width: | Height: | Size: 336 B |
After Width: | Height: | Size: 725 B |
After Width: | Height: | Size: 15 KiB |
|
@ -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>
|
After Width: | Height: | Size: 233 KiB |
After Width: | Height: | Size: 5.2 KiB |
After Width: | Height: | Size: 9.4 KiB |
|
@ -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"
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
});
|
|
@ -0,0 +1,3 @@
|
||||||
|
@import '~antd/dist/antd.css';
|
||||||
|
@import '~antd/dist/antd.dark.less';
|
||||||
|
@primary-color: #2abdd2;
|
|
@ -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 |
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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',
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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']),
|
||||||
|
);
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}`;
|
|
@ -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;
|
||||||
|
}
|
|
@ -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();
|
|
@ -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 |
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="react-scripts" />
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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';
|
|
@ -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;
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 },
|
||||||
|
);
|
||||||
|
}
|
|
@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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,
|
||||||
|
];
|
||||||
|
}
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|