Add page for listing new markets

This commit is contained in:
Gary Wang 2020-09-27 22:49:40 -07:00
parent ce57e3c1e1
commit 37067f2f6c
6 changed files with 407 additions and 52 deletions

View File

@ -1,10 +1,10 @@
import React, { useState, useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { Modal, Input, Row, Col, Typography } from 'antd'; import { Col, Input, Modal, Row, Typography } from 'antd';
import { notify } from '../utils/notifications'; import { notify } from '../utils/notifications';
import { isValidPublicKey } from '../utils/utils'; import { isValidPublicKey } from '../utils/utils';
import { PublicKey } from '@solana/web3.js'; import { PublicKey } from '@solana/web3.js';
import { Market, MARKETS, TOKEN_MINTS } from '@project-serum/serum'; import { Market, MARKETS, TOKEN_MINTS } from '@project-serum/serum';
import { useConnection } from '../utils/connection'; import { useAccountInfo, useConnection } from '../utils/connection';
import { LoadingOutlined } from '@ant-design/icons'; import { LoadingOutlined } from '@ant-design/icons';
const { Text } = Typography; const { Text } = Typography;
@ -15,23 +15,27 @@ export default function CustomMarketDialog({
onClose, onClose,
}) { }) {
const connection = useConnection(); const connection = useConnection();
const [marketId, setMarketId] = useState(null);
const [programId, setProgramId] = useState(
MARKETS.find(({ deprecated }) => !deprecated)?.programId?.toBase58(),
);
const [marketLabel, setMarketLabel] = useState(null); const [marketId, setMarketId] = useState('');
const [baseLabel, setBaseLabel] = useState(null);
const [quoteLabel, setQuoteLabel] = useState(null); const [marketLabel, setMarketLabel] = useState('');
const [baseLabel, setBaseLabel] = useState('');
const [quoteLabel, setQuoteLabel] = useState('');
const [market, setMarket] = useState(null); const [market, setMarket] = useState(null);
const [loadingMarket, setLoadingMarket] = useState(false); const [loadingMarket, setLoadingMarket] = useState(false);
const wellFormedMarketId = isValidPublicKey(marketId); const wellFormedMarketId = isValidPublicKey(marketId);
const wellFormedProgramId = isValidPublicKey(programId);
const [marketAccountInfo] = useAccountInfo(
wellFormedMarketId ? new PublicKey(marketId) : null,
);
const programId = marketAccountInfo
? marketAccountInfo.owner.toBase58()
: MARKETS.find(({ deprecated }) => !deprecated).programId.toBase58();
useEffect(() => { useEffect(() => {
if (!wellFormedMarketId || !wellFormedProgramId) { if (!wellFormedMarketId || !programId) {
resetLabels(); resetLabels();
return; return;
} }
@ -79,12 +83,12 @@ export default function CustomMarketDialog({
const canSubmit = const canSubmit =
!loadingMarket && !loadingMarket &&
!!market && !!market &&
market.publicKey.toBase58() === marketId &&
marketId && marketId &&
programId && programId &&
marketLabel && marketLabel &&
(knownBaseCurrency || baseLabel) && (knownBaseCurrency || baseLabel) &&
(knownQuoteCurrency || quoteLabel) && (knownQuoteCurrency || quoteLabel) &&
wellFormedProgramId &&
wellFormedMarketId; wellFormedMarketId;
const onSubmit = () => { const onSubmit = () => {
@ -115,7 +119,6 @@ export default function CustomMarketDialog({
resetLabels(); resetLabels();
setMarket(null); setMarket(null);
setMarketId(null); setMarketId(null);
setProgramId(null);
onClose(); onClose();
}; };
@ -129,13 +132,11 @@ export default function CustomMarketDialog({
okButtonProps={{ disabled: !canSubmit }} okButtonProps={{ disabled: !canSubmit }}
> >
<div> <div>
{wellFormedMarketId && wellFormedProgramId ? ( {wellFormedMarketId ? (
<> <>
{!market && ( {!market && !loadingMarket && (
<Row style={{ marginBottom: 8 }}> <Row style={{ marginBottom: 8 }}>
<Text type="danger"> <Text type="danger">Not a valid market</Text>
Not a valid market and program ID combination
</Text>
</Row> </Row>
)} )}
{market && knownMarket && ( {market && knownMarket && (
@ -161,11 +162,6 @@ export default function CustomMarketDialog({
<Text type="danger">Invalid market ID</Text> <Text type="danger">Invalid market ID</Text>
</Row> </Row>
)} )}
{marketId && !wellFormedProgramId && (
<Row style={{ marginBottom: 8 }}>
<Text type="danger">Invalid program ID</Text>
</Row>
)}
</> </>
)} )}
<Row style={{ marginBottom: 8 }}> <Row style={{ marginBottom: 8 }}>
@ -179,20 +175,6 @@ export default function CustomMarketDialog({
</Col> </Col>
</Row> </Row>
{programId && !isValidPublicKey(programId) && (
<Row style={{ marginBottom: 8 }}>
<Text type="danger">Invalid program ID</Text>
</Row>
)}
<Row style={{ marginBottom: 8 }}>
<Col span={24}>
<Input
placeholder="Program Id"
value={programId}
onChange={(e) => setProgramId(e.target.value)}
/>
</Col>
</Row>
<Row style={{ marginBottom: 8, marginTop: 8 }}> <Row style={{ marginBottom: 8, marginTop: 8 }}>
<Col span={24}> <Col span={24}>
<Input <Input

View File

@ -1,6 +1,6 @@
import { InfoCircleOutlined, UserOutlined } from '@ant-design/icons'; import { InfoCircleOutlined, UserOutlined } from '@ant-design/icons';
import { Button, Menu, Popover, Select } from 'antd'; import { Button, Menu, Popover, Select } from 'antd';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback } from 'react';
import { useHistory, useLocation } from 'react-router-dom'; import { useHistory, useLocation } from 'react-router-dom';
import logo from '../assets/logo.svg'; import logo from '../assets/logo.svg';
import styled from 'styled-components'; import styled from 'styled-components';
@ -29,7 +29,6 @@ const LogoWrapper = styled.div`
`; `;
export default function TopBar() { export default function TopBar() {
const [current, setCurrent] = useState('/');
const { connected, wallet, providerUrl, setProvider } = useWallet(); const { connected, wallet, providerUrl, setProvider } = useWallet();
const { endpoint, setEndpoint } = useConnectionConfig(); const { endpoint, setEndpoint } = useConnectionConfig();
const location = useLocation(); const location = useLocation();
@ -44,24 +43,16 @@ export default function TopBar() {
[history], [history],
); );
useEffect(() => {
if (location.pathname.includes('/orders')) {
setCurrent('/orders');
} else if (location.pathname.includes('/balances')) {
setCurrent('/balances');
}
}, [location]);
return ( return (
<Wrapper> <Wrapper>
<LogoWrapper> <LogoWrapper>
<img src={logo} alt="" /> <img src={logo} alt="" onClick={() => history.push('/')} />
{'SERUM'} {'SERUM'}
</LogoWrapper> </LogoWrapper>
<Menu <Menu
mode="horizontal" mode="horizontal"
onClick={handleClick} onClick={handleClick}
selectedKeys={[current]} selectedKeys={[location.pathname]}
style={{ style={{
borderBottom: 'none', borderBottom: 'none',
backgroundColor: 'transparent', backgroundColor: 'transparent',

View File

@ -0,0 +1,219 @@
import React, { useState } from 'react';
import { Button, Form, Input, Typography } from 'antd';
import { notify } from '../utils/notifications';
import { isValidPublicKey } from '../utils/utils';
import { PublicKey } from '@solana/web3.js';
import { MARKETS, TokenInstructions } from '@project-serum/serum';
import { useAccountInfo, useConnection } from '../utils/connection';
import FloatingElement from '../components/layout/FloatingElement';
import styled from 'styled-components';
import { parseTokenMintData } from '../utils/tokens';
import { useWallet } from '../utils/wallet';
import { listMarket } from '../utils/send';
const { Text, Title } = Typography;
const Wrapper = styled.div`
max-width: 800px;
margin-left: auto;
margin-right: auto;
margin-top: 24px;
margin-bottom: 24px;
`;
export default function ListNewMarketPage() {
const connection = useConnection();
const { wallet, connected } = useWallet();
const [baseMintInput, baseMintInfo] = useMintInput(
'baseMint',
'Base Mint Address',
);
const [quoteMintInput, quoteMintInfo] = useMintInput(
'quoteMint',
'Quote Mint Address',
);
const [lotSize, setLotSize] = useState('1');
const [tickSize, setTickSize] = useState('0.01');
const dexProgramId = MARKETS.find(({ deprecated }) => !deprecated).programId;
const [submitting, setSubmitting] = useState(false);
const [listedMarket, setListedMarket] = useState(null);
let baseLotSize;
let quoteLotSize;
if (baseMintInfo && parseFloat(lotSize) > 0) {
baseLotSize = Math.round(10 ** baseMintInfo.decimals * parseFloat(lotSize));
if (quoteMintInfo && parseFloat(tickSize) > 0) {
quoteLotSize = Math.round(
parseFloat(lotSize) *
10 ** quoteMintInfo.decimals *
parseFloat(tickSize),
);
}
}
const canSubmit =
connected &&
!!baseMintInfo &&
!!quoteMintInfo &&
!!baseLotSize &&
!!quoteLotSize;
async function onSubmit() {
if (!canSubmit) {
return;
}
setSubmitting(true);
try {
const marketAddress = await listMarket({
connection,
wallet,
baseMint: baseMintInfo.address,
quoteMint: quoteMintInfo.address,
baseLotSize,
quoteLotSize,
dexProgramId,
});
setListedMarket(marketAddress);
} catch (e) {
console.warn(e);
notify({
message: 'Error listing new market: ' + e.message,
type: 'error',
});
} finally {
setSubmitting(false);
}
}
return (
<Wrapper>
<FloatingElement>
<Title level={4}>List New Market</Title>
<Form
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
onFinish={onSubmit}
>
{baseMintInput}
{quoteMintInput}
<Form.Item
label="Min Order Size (Lot Size)"
name="lotSize"
initialValue="1"
validateStatus={
baseMintInfo && quoteMintInfo
? baseLotSize
? 'success'
: 'error'
: null
}
hasFeedback={baseMintInfo && quoteMintInfo}
>
<Input
value={lotSize}
onChange={(e) => setLotSize(e.target.value.trim())}
type="number"
min="0"
step="any"
/>
</Form.Item>
<Form.Item
label="Tick Size (Price Increment)"
name="tickSize"
initialValue="0.01"
validateStatus={
baseMintInfo && quoteMintInfo
? quoteLotSize
? 'success'
: 'error'
: null
}
hasFeedback={baseMintInfo && quoteMintInfo}
>
<Input
value={tickSize}
onChange={(e) => setTickSize(e.target.value.trim())}
type="number"
min="0"
step="any"
/>
</Form.Item>
<Form.Item wrapperCol={{ offset: 8, span: 16 }}>
<Button
type="primary"
htmlType="submit"
disabled={!canSubmit}
loading={submitting}
>
{connected ? 'Submit' : 'Not connected to wallet'}
</Button>
</Form.Item>
</Form>
</FloatingElement>
{listedMarket ? (
<FloatingElement>
<Text>New market address: {listedMarket.toBase58()}</Text>
</FloatingElement>
) : null}
</Wrapper>
);
}
function useMintInput(name, label) {
const [address, setAddress] = useState('');
const [accountInfo, loaded] = useAccountInfo(
isValidPublicKey(address) ? new PublicKey(address) : null,
);
let validateStatus = null;
let hasFeedback = false;
let help = null;
let mintInfo = null;
if (address) {
hasFeedback = true;
if (accountInfo) {
if (
accountInfo.owner.equals(TokenInstructions.TOKEN_PROGRAM_ID) &&
accountInfo.data.length === 82
) {
let parsed = parseTokenMintData(accountInfo.data);
if (parsed.initialized) {
validateStatus = 'success';
mintInfo = {
address: new PublicKey(address),
decimals: parsed.decimals,
};
} else {
validateStatus = 'error';
help = 'Invalid SPL mint';
}
} else {
validateStatus = 'error';
help = 'Invalid SPL mint address';
}
} else if (isValidPublicKey(address) && !loaded) {
validateStatus = 'loading';
} else {
validateStatus = 'error';
help = 'Invalid Solana address';
}
}
const input = (
<Form.Item
label={label}
name={name}
validateStatus={validateStatus}
hasFeedback={hasFeedback}
help={help}
>
<Input
value={address}
onChange={(e) => setAddress(e.target.value.trim())}
/>
</Form.Item>
);
return [input, mintInfo];
}

View File

@ -4,6 +4,7 @@ import OpenOrdersPage from './pages/OpenOrdersPage';
import React from 'react'; import React from 'react';
import BalancesPage from './pages/BalancesPage'; import BalancesPage from './pages/BalancesPage';
import BasicLayout from './components/BasicLayout'; import BasicLayout from './components/BasicLayout';
import ListNewMarketPage from './pages/ListNewMarketPage';
export function Routes() { export function Routes() {
return ( return (
@ -11,6 +12,11 @@ export function Routes() {
<Route exact path="/" component={TradePageContents} /> <Route exact path="/" component={TradePageContents} />
<Route exact path="/orders" component={OpenOrdersPageContents} /> <Route exact path="/orders" component={OpenOrdersPageContents} />
<Route exact path="/balances" component={BalancesPageContents} /> <Route exact path="/balances" component={BalancesPageContents} />
<Route exact path="/list-new-market">
<BasicLayout>
<ListNewMarketPage />
</BasicLayout>
</Route>
</HashRouter> </HashRouter>
); );
} }

View File

@ -2,11 +2,17 @@ import { notify } from './notifications';
import { getDecimalCount, sleep } from './utils'; import { getDecimalCount, sleep } from './utils';
import { import {
Account, Account,
PublicKey,
SystemProgram, SystemProgram,
Transaction, Transaction,
PublicKey,
} from '@solana/web3.js'; } from '@solana/web3.js';
import { TOKEN_MINTS, TokenInstructions } from '@project-serum/serum'; import { BN } from 'bn.js';
import {
DexInstructions,
Market,
TOKEN_MINTS,
TokenInstructions,
} from '@project-serum/serum';
export async function createTokenAccountTransaction({ export async function createTokenAccountTransaction({
connection, connection,
@ -249,6 +255,145 @@ export async function placeOrder({
}); });
} }
export async function listMarket({
connection,
wallet,
baseMint,
quoteMint,
baseLotSize,
quoteLotSize,
dexProgramId,
}) {
const market = new Account();
const requestQueue = new Account();
const eventQueue = new Account();
const bids = new Account();
const asks = new Account();
const baseVault = new Account();
const quoteVault = new Account();
const feeRateBps = 0;
const quoteDustThreshold = new BN(100);
async function getVaultOwnerAndNonce() {
const nonce = new BN(0);
while (true) {
try {
const vaultOwner = await PublicKey.createProgramAddress(
[market.publicKey.toBuffer(), nonce.toArrayLike(Buffer, 'le', 8)],
dexProgramId,
);
return [vaultOwner, nonce];
} catch (e) {
nonce.iaddn(1);
}
}
}
const [vaultOwner, vaultSignerNonce] = await getVaultOwnerAndNonce();
const tx1 = new Transaction();
tx1.add(
SystemProgram.createAccount({
fromPubkey: wallet.publicKey,
newAccountPubkey: baseVault.publicKey,
lamports: await connection.getMinimumBalanceForRentExemption(165),
space: 165,
programId: TokenInstructions.TOKEN_PROGRAM_ID,
}),
SystemProgram.createAccount({
fromPubkey: wallet.publicKey,
newAccountPubkey: quoteVault.publicKey,
lamports: await connection.getMinimumBalanceForRentExemption(165),
space: 165,
programId: TokenInstructions.TOKEN_PROGRAM_ID,
}),
TokenInstructions.initializeAccount({
account: baseVault.publicKey,
mint: baseMint,
owner: vaultOwner,
}),
TokenInstructions.initializeAccount({
account: quoteVault.publicKey,
mint: quoteMint,
owner: vaultOwner,
}),
);
const tx2 = new Transaction();
tx2.add(
SystemProgram.createAccount({
fromPubkey: wallet.publicKey,
newAccountPubkey: market.publicKey,
lamports: await connection.getMinimumBalanceForRentExemption(
Market.getLayout(dexProgramId).span,
),
space: Market.getLayout(dexProgramId).span,
programId: dexProgramId,
}),
SystemProgram.createAccount({
fromPubkey: wallet.publicKey,
newAccountPubkey: requestQueue.publicKey,
lamports: await connection.getMinimumBalanceForRentExemption(5120 + 12),
space: 5120 + 12,
programId: dexProgramId,
}),
SystemProgram.createAccount({
fromPubkey: wallet.publicKey,
newAccountPubkey: eventQueue.publicKey,
lamports: await connection.getMinimumBalanceForRentExemption(262144 + 12),
space: 262144 + 12,
programId: dexProgramId,
}),
SystemProgram.createAccount({
fromPubkey: wallet.publicKey,
newAccountPubkey: bids.publicKey,
lamports: await connection.getMinimumBalanceForRentExemption(65536 + 12),
space: 65536 + 12,
programId: dexProgramId,
}),
SystemProgram.createAccount({
fromPubkey: wallet.publicKey,
newAccountPubkey: asks.publicKey,
lamports: await connection.getMinimumBalanceForRentExemption(65536 + 12),
space: 65536 + 12,
programId: dexProgramId,
}),
DexInstructions.initializeMarket({
market: market.publicKey,
requestQueue: requestQueue.publicKey,
eventQueue: eventQueue.publicKey,
bids: bids.publicKey,
asks: asks.publicKey,
baseVault: baseVault.publicKey,
quoteVault: quoteVault.publicKey,
baseMint,
quoteMint,
baseLotSize: new BN(baseLotSize),
quoteLotSize: new BN(quoteLotSize),
feeRateBps,
vaultSignerNonce,
quoteDustThreshold,
programId: dexProgramId,
}),
);
await Promise.all([
sendTransaction({
transaction: tx1,
wallet,
connection,
signers: [wallet.publicKey, baseVault, quoteVault],
}),
sendTransaction({
transaction: tx2,
wallet,
connection,
signers: [wallet.publicKey, market, requestQueue, eventQueue, bids, asks],
}),
]);
return market.publicKey;
}
const getUnixTs = () => { const getUnixTs = () => {
return new Date().getTime() / 1000; return new Date().getTime() / 1000;
}; };

View File

@ -10,6 +10,13 @@ export const ACCOUNT_LAYOUT = BufferLayout.struct([
BufferLayout.blob(93), BufferLayout.blob(93),
]); ]);
export const MINT_LAYOUT = BufferLayout.struct([
BufferLayout.blob(44),
BufferLayout.u8('decimals'),
BufferLayout.u8('initialized'),
BufferLayout.blob(36),
]);
export function parseTokenAccountData(data) { export function parseTokenAccountData(data) {
let { mint, owner, amount } = ACCOUNT_LAYOUT.decode(data); let { mint, owner, amount } = ACCOUNT_LAYOUT.decode(data);
return { return {
@ -19,6 +26,11 @@ export function parseTokenAccountData(data) {
}; };
} }
export function parseTokenMintData(data) {
let { decimals, initialized } = MINT_LAYOUT.decode(data);
return { decimals, initialized };
}
export function getOwnedAccountsFilters(publicKey) { export function getOwnedAccountsFilters(publicKey) {
return [ return [
{ {