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"
|
||||
]
|
||||
}
|