Compare commits

...

31 Commits

Author SHA1 Message Date
armaniferrante d41271f9c9
v0.2.5 2021-11-23 08:45:42 -08:00
armaniferrante 1aae052784
v0.2.4 2021-10-22 08:30:52 -07:00
armaniferrante 9cc0495d4a
Bump @project-serum/swap dependency 2021-10-22 08:25:33 -07:00
Shardul Aeer 3f213aa5ae
fix: Empty token buffer decode error (#84) 2021-09-18 14:30:22 -05:00
Shardul Aeer 80b0fa4605
bump: Update all dependencies (#80) 2021-09-05 08:48:45 -07:00
Armani Ferrante 4a92cad65a
Update @project-serum/swap dependency (#78) 2021-09-03 10:46:10 -07:00
armaniferrante 8333da03ad
v0.2.2 2021-08-08 12:56:12 -07:00
armaniferrante 318801a876
Update token account fetch for rpc node changes 2021-08-08 12:53:55 -07:00
Armani Ferrante 879c212da6
Don't show aux accounts (#70) 2021-07-12 20:59:28 -07:00
armaniferrante 60c53df8b4
v0.2.0 2021-07-09 00:12:22 -07:00
secretshardul e67506f523
fix: Remove global CSS, use commonjs to support Next.js (#53) 2021-07-09 00:09:08 -07:00
armaniferrante fd035017a5
Wrap/unwrap sol in the same tx 2021-06-28 16:25:52 -07:00
Armani Ferrante 958f809be5
Distinguish SOL from Wrapped SOL (#60) 2021-06-24 19:01:38 -07:00
Armani Ferrante 671225aec8
Create associated token accounts and wrap SOL (#59) 2021-06-24 15:32:34 -07:00
armaniferrante c64caec9d5
v0.1.8 2021-06-22 22:59:36 -07:00
armaniferrante 999f234165
v0.1.7 2021-06-22 22:45:38 -07:00
armaniferrante 921c22e4e0
Bump @project-serum/swap 2021-06-22 22:36:23 -07:00
Armani Ferrante a3e841bf8d
Check for empty orderbook (#58) 2021-06-22 21:40:18 -07:00
Armani Ferrante 15c9845bdc
Hide tabs at extension width (#55) 2021-06-16 16:18:32 -07:00
secretshardul 65284636de
fix: Make prepare script independent of package manager (#54) 2021-06-16 08:44:54 -07:00
Armani Ferrante a589adcd3f
v0.1.5 (#51) 2021-06-15 18:35:24 -07:00
Armani Ferrante 799b67aa92
Recalculate toAmount when swapping currencies (#50) 2021-06-15 18:34:19 -07:00
Armani Ferrante 1ef329d34a
v0.1.4 (#49) 2021-06-15 12:23:40 -07:00
secretshardul 12e7f94d8e
Mobile friendly swap widget using Material UI's 'theme.spacing()' (#37) 2021-06-15 12:02:50 -07:00
Armani Ferrante 09d67dd99f
Fix USDT -> USDC swaps (#48) 2021-06-15 11:30:17 -07:00
Armani Ferrante 6125d18e3b
Adjust UI displayed toAmount for taker fees (#47) 2021-06-15 10:49:17 -07:00
secretshardul b23d21b0a7
Export providers and low level components (#45) 2021-06-13 10:32:34 -07:00
Armani Ferrante 1886a484cb
fix: Sort token accounts by balances (#42) 2021-06-11 16:21:51 -07:00
secretshardul edbc600b84
Enable module installs from Github with prepare script (#41) 2021-06-11 11:35:43 -07:00
Armani Ferrante 5793336025
v0.1.2 (#39) 2021-06-10 19:21:09 -07:00
Armani Ferrante 5d745f3dd9
Fetch decimals before creating the market client (#38) 2021-06-10 19:15:34 -07:00
15 changed files with 2238 additions and 2427 deletions

View File

@ -1,6 +1,7 @@
import "@fontsource/roboto";
import { useState, useEffect, useMemo } from "react";
import { SnackbarProvider, useSnackbar } from "notistack";
import { Button } from "@material-ui/core";
import { Button, Grid, makeStyles } from "@material-ui/core";
import { Provider } from "@project-serum/anchor";
// @ts-ignore
import Wallet from "@project-serum/sol-wallet-adapter";
@ -10,6 +11,7 @@ import {
Connection,
Transaction,
TransactionSignature,
PublicKey,
} from "@solana/web3.js";
import {
TokenListContainer,
@ -30,7 +32,16 @@ function App() {
);
}
const useStyles = makeStyles((theme) => ({
root: {
minHeight: "100vh",
paddingLeft: theme.spacing(1),
paddingRight: theme.spacing(1),
},
}));
function AppInner() {
const styles = useStyles();
const { enqueueSnackbar } = useSnackbar();
const [isConnected, setIsConnected] = useState(false);
const [tokenList, setTokenList] = useState<TokenListContainer | null>(null);
@ -90,20 +101,11 @@ function AppInner() {
}, [wallet, enqueueSnackbar]);
return (
<div
style={{
width: "450px",
marginLeft: "auto",
marginRight: "auto",
position: "absolute",
left: 0,
right: 0,
top: 0,
bottom: 0,
display: "flex",
justifyContent: "center",
flexDirection: "column",
}}
<Grid
container
justify="center"
alignItems="center"
className={styles.root}
>
<Button
variant="outlined"
@ -113,10 +115,17 @@ function AppInner() {
{!isConnected ? "Connect" : "Disconnect"}
</Button>
{tokenList && <Swap provider={provider} tokenList={tokenList} />}
</div>
</Grid>
);
}
// Cast wallet to AnchorWallet in order to be compatible with Anchor's Provider class
interface AnchorWallet {
signTransaction(tx: Transaction): Promise<Transaction>;
signAllTransactions(txs: Transaction[]): Promise<Transaction[]>;
publicKey: PublicKey;
}
// Custom provider to display notifications whenever a transaction is sent.
//
// Note that this is an Anchor wallet/network provider--not a React provider,
@ -136,7 +145,8 @@ class NotifyingProvider extends Provider {
opts: ConfirmOptions,
onTransaction: (tx: TransactionSignature | undefined, err?: Error) => void
) {
super(connection, wallet, opts);
const newWallet = wallet as AnchorWallet;
super(connection, newWallet, opts);
this.onTransaction = onTransaction;
}
@ -150,7 +160,9 @@ class NotifyingProvider extends Provider {
this.onTransaction(txSig);
return txSig;
} catch (err) {
this.onTransaction(undefined, err);
if (err instanceof Error || err === undefined) {
this.onTransaction(undefined, err);
}
return "";
}
}
@ -166,7 +178,9 @@ class NotifyingProvider extends Provider {
});
return txSigs;
} catch (err) {
this.onTransaction(undefined, err);
if (err instanceof Error || err === undefined) {
this.onTransaction(undefined, err);
}
return [];
}
}

View File

@ -1,3 +1,4 @@
@import url("https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;700&display=swap");
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",

View File

@ -1,24 +1,23 @@
{
"name": "@project-serum/swap-ui",
"version": "0.1.0",
"version": "0.2.5",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"homepage": "https://github.com/project-serum/swap-ui",
"license": "Apache-2.0",
"dependencies": {
"@fontsource/roboto": "^4.3.0",
"@project-serum/serum": "^0.13.34",
"@project-serum/swap": "^0.1.0-alpha.20",
"@solana/spl-token": "^0.1.4"
"@project-serum/serum": "^0.13.58",
"@project-serum/swap": "^0.1.0-alpha.35",
"@solana/spl-token": "^0.1.8"
},
"peerDependencies": {
"@material-ui/core": "^4.11.4",
"@material-ui/core": "^4.12.3",
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "^4.0.0-alpha.58",
"@project-serum/anchor": "^0.7.0",
"@solana/spl-token-registry": "^0.2.86",
"@solana/web3.js": "^1.10.1",
"material-ui-popup-state": "^1.8.3",
"@material-ui/lab": "^4.0.0-alpha.60",
"@project-serum/anchor": "^0.14.0",
"@solana/spl-token-registry": "^0.2.229",
"@solana/web3.js": "^1.17.1",
"material-ui-popup-state": "^1.9.3",
"react": "^17.0.2",
"react-async-hook": "^3.6.2"
},
@ -26,7 +25,8 @@
"lint": "prettier src/** example/src/** --check",
"lint:fix": "prettier src/** example/src/** -w",
"build": "rm -rf dist && tsc --build tsconfig.json",
"docs": "typedoc --excludePrivate --out ./docs src/index.tsx --includeVersion --readme none"
"docs": "typedoc --excludePrivate --out ./docs src/index.tsx --includeVersion --readme none",
"prepare": "tsc --build tsconfig.json"
},
"eslintConfig": {
"extends": [
@ -35,38 +35,39 @@
]
},
"devDependencies": {
"@material-ui/core": "^4.11.4",
"@fontsource/roboto": "4.5.0",
"@material-ui/core": "^4.12.3",
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "^4.0.0-alpha.58",
"@project-serum/anchor": "^0.7.0",
"@project-serum/serum": "^0.13.34",
"@project-serum/sol-wallet-adapter": "^0.2.0",
"@project-serum/swap": "^0.1.0-alpha.20",
"@solana/spl-token": "^0.1.4",
"@solana/spl-token-registry": "^0.2.86",
"@solana/web3.js": "^1.10.1",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@material-ui/lab": "^4.0.0-alpha.60",
"@project-serum/anchor": "^0.14.0",
"@project-serum/serum": "^0.13.58",
"@project-serum/sol-wallet-adapter": "^0.2.5",
"@project-serum/swap": "^0.1.0-alpha.35",
"@solana/spl-token": "^0.1.8",
"@solana/spl-token-registry": "^0.2.229",
"@solana/web3.js": "^1.24.1",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.0.0",
"@testing-library/user-event": "^13.2.1",
"@types/bs58": "^4.0.1",
"@types/jest": "^26.0.15",
"@types/node": "^12.0.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/jest": "^27.0.1",
"@types/node": "^16.7.10",
"@types/react": "^17.0.19",
"@types/react-dom": "^17.0.9",
"bs58": "^4.0.1",
"gh-pages": "^3.1.0",
"material-ui-popup-state": "^1.8.3",
"notistack": "^1.0.7",
"prettier": "^2.3.0",
"gh-pages": "^3.2.3",
"material-ui-popup-state": "^1.9.3",
"notistack": "^1.0.10",
"prettier": "^2.3.2",
"react": "^17.0.2",
"react-app-rewire-alias": "^1.0.3",
"react-app-rewired": "^2.1.8",
"react-async-hook": "^3.6.2",
"react-dom": "^17.0.2",
"react-scripts": "4.0.3",
"typedoc": "^0.20.36",
"typescript": "^4.1.2",
"web-vitals": "^1.0.1"
"typedoc": "^0.21.9",
"typescript": "^4.4.2",
"web-vitals": "^2.1.0"
},
"files": [
"dist"

View File

@ -1,6 +1,13 @@
import { useState } from "react";
import { PublicKey } from "@solana/web3.js";
import { BN } from "@project-serum/anchor";
import {
PublicKey,
Keypair,
Transaction,
SystemProgram,
Signer,
} from "@solana/web3.js";
import { Token, TOKEN_PROGRAM_ID } from "@solana/spl-token";
import { BN, Provider } from "@project-serum/anchor";
import {
makeStyles,
Card,
@ -16,7 +23,7 @@ import {
useOpenOrders,
useRouteVerbose,
useMarket,
BASE_TAKER_FEE_BPS,
FEE_MULTIPLIER,
} from "../context/Dex";
import { useTokenMap } from "../context/TokenList";
import { useMint, useOwnedTokenAccount } from "../context/Token";
@ -24,13 +31,14 @@ import { useCanSwap, useReferral } from "../context/Swap";
import TokenDialog from "./TokenDialog";
import { SettingsButton } from "./Settings";
import { InfoLabel } from "./Info";
import { SOL_MINT, WRAPPED_SOL_MINT } from "../utils/pubkeys";
const useStyles = makeStyles((theme) => ({
card: {
width: "450px",
borderRadius: "16px",
width: theme.spacing(50),
borderRadius: theme.spacing(2),
boxShadow: "0px 0px 30px 5px rgba(0,0,0,0.075)",
padding: "16px",
padding: theme.spacing(2),
},
tab: {
width: "50%",
@ -40,12 +48,12 @@ const useStyles = makeStyles((theme) => ({
},
swapButton: {
width: "100%",
borderRadius: "10px",
borderRadius: theme.spacing(2),
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
fontSize: 16,
fontWeight: 700,
padding: "10px",
padding: theme.spacing(1.5),
},
swapToFromButton: {
display: "block",
@ -60,14 +68,14 @@ const useStyles = makeStyles((theme) => ({
textAlign: "right",
},
swapTokenFormContainer: {
borderRadius: "10px",
borderRadius: theme.spacing(2),
boxShadow: "0px 0px 15px 2px rgba(33,150,243,0.1)",
display: "flex",
justifyContent: "space-between",
padding: "10px",
padding: theme.spacing(1),
},
swapTokenSelectorContainer: {
marginLeft: "5px",
marginLeft: theme.spacing(1),
display: "flex",
flexDirection: "column",
width: "50%",
@ -78,7 +86,7 @@ const useStyles = makeStyles((theme) => ({
fontSize: "14px",
},
maxButton: {
marginLeft: 10,
marginLeft: theme.spacing(1),
color: theme.palette.primary.main,
fontWeight: 700,
fontSize: "12px",
@ -88,7 +96,7 @@ const useStyles = makeStyles((theme) => ({
display: "flex",
alignItems: "center",
cursor: "pointer",
marginBottom: "10px",
marginBottom: theme.spacing(1),
},
}));
@ -116,13 +124,13 @@ export default function SwapCard({
);
}
function SwapHeader() {
export function SwapHeader() {
return (
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: "20px",
marginBottom: "16px",
}}
>
<Typography
@ -180,7 +188,7 @@ function SwapToForm({ style }: { style?: any }) {
);
}
function SwapTokenForm({
export function SwapTokenForm({
from,
style,
mint,
@ -261,10 +269,11 @@ function TokenButton({
onClick: () => void;
}) {
const styles = useStyles();
const theme = useTheme();
return (
<div onClick={onClick} className={styles.tokenButton}>
<TokenIcon mint={mint} style={{ width: "30px" }} />
<TokenIcon mint={mint} style={{ width: theme.spacing(4) }} />
<TokenName mint={mint} style={{ fontSize: 14, fontWeight: 700 }} />
<ExpandMore />
</div>
@ -293,15 +302,23 @@ export function TokenIcon({ mint, style }: { mint: PublicKey; style: any }) {
function TokenName({ mint, style }: { mint: PublicKey; style: any }) {
const tokenMap = useTokenMap();
const theme = useTheme();
let tokenInfo = tokenMap.get(mint.toString());
return (
<Typography style={{ marginLeft: "10px", marginRight: "5px", ...style }}>
<Typography
style={{
marginLeft: theme.spacing(2),
marginRight: theme.spacing(1),
...style,
}}
>
{tokenInfo?.symbol}
</Typography>
);
}
function SwapButton() {
export function SwapButton() {
const styles = useStyles();
const {
fromMint,
@ -325,9 +342,11 @@ function SwapButton() {
const canSwap = useCanSwap();
const referral = useReferral(fromMarket);
const fair = useSwapFair();
const fromWallet = useOwnedTokenAccount(fromMint);
const toWallet = useOwnedTokenAccount(toMint);
const quoteMint = useMint(fromMarket && fromMarket.quoteMintAddress);
let fromWallet = useOwnedTokenAccount(fromMint);
let toWallet = useOwnedTokenAccount(toMint);
const quoteMint = fromMarket && fromMarket.quoteMintAddress;
const quoteMintInfo = useMint(quoteMint);
const quoteWallet = useOwnedTokenAccount(quoteMint);
// Click handler.
const sendSwapTransaction = async () => {
@ -337,43 +356,90 @@ function SwapButton() {
if (!fair) {
throw new Error("Invalid fair");
}
if (!quoteMint) {
if (!quoteMint || !quoteMintInfo) {
throw new Error("Quote mint not found");
}
const amount = new BN(fromAmount * 10 ** fromMintInfo.decimals);
const minExchangeRate = {
rate: new BN(
(10 ** toMintInfo.decimals * (1 - BASE_TAKER_FEE_BPS)) / fair
)
.muln(100 - slippage)
.divn(100),
fromDecimals: fromMintInfo.decimals,
quoteDecimals: quoteMint.decimals,
strict: isStrict,
};
const fromOpenOrders = fromMarket
? openOrders.get(fromMarket?.address.toString())
: undefined;
const toOpenOrders = toMarket
? openOrders.get(toMarket?.address.toString())
: undefined;
await swapClient.swap({
fromMint,
toMint,
fromWallet: fromWallet ? fromWallet.publicKey : undefined,
toWallet: toWallet ? toWallet.publicKey : undefined,
amount,
minExchangeRate,
referral,
// Pass in the below parameters so that the client doesn't perform
// wasteful network requests when we already have the data.
fromMarket,
toMarket,
fromOpenOrders: fromOpenOrders ? fromOpenOrders[0].address : undefined,
toOpenOrders: toOpenOrders ? toOpenOrders[0].address : undefined,
// Auto close newly created open orders accounts.
close: isClosingNewAccounts,
});
const isSol = fromMint.equals(SOL_MINT) || toMint.equals(SOL_MINT);
const wrappedSolAccount = isSol ? Keypair.generate() : undefined;
// Build the swap.
let txs = await (async () => {
if (!fromMarket) {
throw new Error("Market undefined");
}
const minExchangeRate = {
rate: new BN((10 ** toMintInfo.decimals * FEE_MULTIPLIER) / fair)
.muln(100 - slippage)
.divn(100),
fromDecimals: fromMintInfo.decimals,
quoteDecimals: quoteMintInfo.decimals,
strict: isStrict,
};
const fromOpenOrders = fromMarket
? openOrders.get(fromMarket?.address.toString())
: undefined;
const toOpenOrders = toMarket
? openOrders.get(toMarket?.address.toString())
: undefined;
const fromWalletAddr = fromMint.equals(SOL_MINT)
? wrappedSolAccount!.publicKey
: fromWallet
? fromWallet.publicKey
: undefined;
const toWalletAddr = toMint.equals(SOL_MINT)
? wrappedSolAccount!.publicKey
: toWallet
? toWallet.publicKey
: undefined;
return await swapClient.swapTxs({
fromMint,
toMint,
quoteMint,
amount,
minExchangeRate,
referral,
fromMarket,
toMarket,
// Automatically created if undefined.
fromOpenOrders: fromOpenOrders ? fromOpenOrders[0].address : undefined,
toOpenOrders: toOpenOrders ? toOpenOrders[0].address : undefined,
fromWallet: fromWalletAddr,
toWallet: toWalletAddr,
quoteWallet: quoteWallet ? quoteWallet.publicKey : undefined,
// Auto close newly created open orders accounts.
close: isClosingNewAccounts,
});
})();
// If swapping SOL, then insert a wrap/unwrap instruction.
if (isSol) {
if (txs.length > 1) {
throw new Error("SOL must be swapped in a single transaction");
}
const { tx: wrapTx, signers: wrapSigners } = await wrapSol(
swapClient.program.provider,
wrappedSolAccount as Keypair,
fromMint,
amount
);
const { tx: unwrapTx, signers: unwrapSigners } = unwrapSol(
swapClient.program.provider,
wrappedSolAccount as Keypair
);
const tx = new Transaction();
tx.add(wrapTx);
tx.add(txs[0].tx);
tx.add(unwrapTx);
txs[0].tx = tx;
txs[0].signers.push(...wrapSigners);
txs[0].signers.push(...unwrapSigners);
}
await swapClient.program.provider.sendAll(txs);
};
return (
<Button
@ -386,3 +452,63 @@ function SwapButton() {
</Button>
);
}
async function wrapSol(
provider: Provider,
wrappedSolAccount: Keypair,
fromMint: PublicKey,
amount: BN
): Promise<{ tx: Transaction; signers: Array<Signer | undefined> }> {
const tx = new Transaction();
const signers = [wrappedSolAccount];
// Create new, rent exempt account.
tx.add(
SystemProgram.createAccount({
fromPubkey: provider.wallet.publicKey,
newAccountPubkey: wrappedSolAccount.publicKey,
lamports: await Token.getMinBalanceRentForExemptAccount(
provider.connection
),
space: 165,
programId: TOKEN_PROGRAM_ID,
})
);
// Transfer lamports. These will be converted to an SPL balance by the
// token program.
if (fromMint.equals(SOL_MINT)) {
tx.add(
SystemProgram.transfer({
fromPubkey: provider.wallet.publicKey,
toPubkey: wrappedSolAccount.publicKey,
lamports: amount.toNumber(),
})
);
}
// Initialize the account.
tx.add(
Token.createInitAccountInstruction(
TOKEN_PROGRAM_ID,
WRAPPED_SOL_MINT,
wrappedSolAccount.publicKey,
provider.wallet.publicKey
)
);
return { tx, signers };
}
function unwrapSol(
provider: Provider,
wrappedSolAccount: Keypair
): { tx: Transaction; signers: Array<Signer | undefined> } {
const tx = new Transaction();
tx.add(
Token.createCloseAccountInstruction(
TOKEN_PROGRAM_ID,
wrappedSolAccount.publicKey,
provider.wallet.publicKey,
provider.wallet.publicKey,
[]
)
);
return { tx, signers: [] };
}

View File

@ -16,6 +16,7 @@ import {
} from "@material-ui/core";
import { TokenIcon } from "./Swap";
import { useSwappableTokens } from "../context/TokenList";
import { useMediaQuery } from "@material-ui/core";
const useStyles = makeStyles((theme) => ({
dialogContent: {
@ -53,6 +54,7 @@ export default function TokenDialog({
const styles = useStyles();
const { swappableTokens, swappableTokensSollet, swappableTokensWormhole } =
useSwappableTokens();
const displayTabs = !useMediaQuery("(max-width:450px)");
const selectedTokens =
tabSelection === 0
? swappableTokens
@ -107,34 +109,36 @@ export default function TokenDialog({
))}
</List>
</DialogContent>
<DialogActions>
<Tabs
value={tabSelection}
onChange={(e, v) => setTabSelection(v)}
classes={{
indicator: styles.tabIndicator,
}}
>
<Tab
value={0}
className={styles.tab}
classes={{ selected: styles.tabSelected }}
label="Main"
/>
<Tab
value={1}
className={styles.tab}
classes={{ selected: styles.tabSelected }}
label="Wormhole"
/>
<Tab
value={2}
className={styles.tab}
classes={{ selected: styles.tabSelected }}
label="Sollet"
/>
</Tabs>
</DialogActions>
{displayTabs && (
<DialogActions>
<Tabs
value={tabSelection}
onChange={(e, v) => setTabSelection(v)}
classes={{
indicator: styles.tabIndicator,
}}
>
<Tab
value={0}
className={styles.tab}
classes={{ selected: styles.tabSelected }}
label="Main"
/>
<Tab
value={1}
className={styles.tab}
classes={{ selected: styles.tabSelected }}
label="Wormhole"
/>
<Tab
value={2}
className={styles.tab}
classes={{ selected: styles.tabSelected }}
label="Sollet"
/>
</Tabs>
</DialogActions>
)}
</Dialog>
);
}

View File

@ -1,4 +1,5 @@
import React, { useContext, useState, useEffect } from "react";
import * as assert from "assert";
import { useAsync } from "react-async-hook";
import { TokenInfo } from "@solana/spl-token-registry";
import { MintLayout } from "@solana/spl-token";
@ -14,6 +15,8 @@ import {
DEX_PID,
USDC_MINT,
USDT_MINT,
SOL_MINT,
WRAPPED_SOL_MINT,
WORM_USDC_MINT,
WORM_USDT_MINT,
WORM_USDC_MARKET,
@ -24,7 +27,8 @@ import { useTokenMap, useTokenListContext } from "./TokenList";
import { fetchSolletInfo, requestWormholeSwapMarketIfNeeded } from "./Sollet";
import { setMintCache } from "./Token";
export const BASE_TAKER_FEE_BPS = 0.0022;
const BASE_TAKER_FEE_BPS = 0.0022;
export const FEE_MULTIPLIER = 1 - BASE_TAKER_FEE_BPS;
type DexContext = {
// Maps market address to open orders accounts.
@ -94,19 +98,14 @@ export function DexContextProvider(props: any) {
publicKey: programAccount?.publicKey,
account: new Market(
Market.getLayout(DEX_PID).decode(programAccount?.account.data),
-1, // Not used so don't bother fetching.
-1, // Not used so don't bother fetching.
-1, // Set below so that we can batch fetch mints.
-1, // Set below so that we can batch fetch mints.
swapClient.program.provider.opts,
DEX_PID
),
};
});
marketClients.forEach((m) => {
_MARKET_CACHE.set(
m.publicKey!.toString(),
new Promise<Market>((resolve) => resolve(m.account))
);
});
setOoAccounts(newOoAccounts);
// Batch fetch all the mints, since we know we'll need them at some
@ -131,8 +130,28 @@ export function DexContextProvider(props: any) {
swapClient.program.provider.connection,
mintPubkeys
);
mints.forEach((mint) => {
setMintCache(mint!.publicKey, MintLayout.decode(mint!.account.data));
const mintInfos = mints.map((mint) => {
const mintInfo = MintLayout.decode(mint!.account.data);
setMintCache(mint!.publicKey, mintInfo);
return { publicKey: mint!.publicKey, mintInfo };
});
marketClients.forEach((m) => {
const baseMintInfo = mintInfos.filter((mint) =>
mint.publicKey.equals(m.account.baseMintAddress)
)[0];
const quoteMintInfo = mintInfos.filter((mint) =>
mint.publicKey.equals(m.account.quoteMintAddress)
)[0];
assert.ok(baseMintInfo && quoteMintInfo);
// @ts-ignore
m.account._baseSplTokenDecimals = baseMintInfo.mintInfo.decimals;
// @ts-ignore
m.account._quoteSplTokenDecimals = quoteMintInfo.mintInfo.decimals;
_MARKET_CACHE.set(
m.publicKey!.toString(),
new Promise<Market>((resolve) => resolve(m.account))
);
});
});
}, [
@ -179,15 +198,11 @@ export function useMarket(market?: PublicKey): Market | undefined {
}
const marketClient = new Promise<Market>(async (resolve) => {
const marketAccount =
await swapClient.program.provider.connection.getAccountInfo(market);
if (marketAccount === null) {
throw new Error("Invalid market");
}
const marketClient = new Market(
Market.getLayout(DEX_PID).decode(marketAccount.data),
-1,
-1,
// TODO: if we already have the mints, then pass them through to the
// market client here to save a network request.
const marketClient = await Market.load(
swapClient.program.provider.connection,
market,
swapClient.program.provider.opts,
DEX_PID
);
@ -343,6 +358,9 @@ export function useBbo(market?: PublicKey): Bbo | undefined {
}
const bestBid = orderbook.bids.items(true).next().value;
const bestOffer = orderbook.asks.items(false).next().value;
if (!bestBid && !bestOffer) {
return {};
}
if (!bestBid) {
return { bestOffer: bestOffer.price };
}
@ -373,7 +391,11 @@ export function useFairRoute(
if (fromMarket === undefined) {
return undefined;
}
if (fromMarket?.baseMintAddress.equals(fromMint)) {
if (
fromMarket?.baseMintAddress.equals(fromMint) ||
(fromMarket?.baseMintAddress.equals(WRAPPED_SOL_MINT) &&
fromMint.equals(SOL_MINT))
) {
return fromBbo.bestBid && 1.0 / fromBbo.bestBid;
} else {
return fromBbo.bestOffer && fromBbo.bestOffer;
@ -426,7 +448,10 @@ export function useRouteVerbose(
const [wormholeMarket, kind] = swapMarket;
return { markets: [wormholeMarket], kind };
}
const markets = swapClient.route(fromMint, toMint);
const markets = swapClient.route(
fromMint.equals(SOL_MINT) ? WRAPPED_SOL_MINT : fromMint,
toMint.equals(SOL_MINT) ? WRAPPED_SOL_MINT : toMint
);
if (markets === null) {
return null;
}

View File

@ -1,5 +1,5 @@
import * as assert from "assert";
import React, { useContext, useState } from "react";
import React, { useContext, useState, useEffect } from "react";
import { useAsync } from "react-async-hook";
import { PublicKey } from "@solana/web3.js";
import {
@ -9,12 +9,18 @@ import {
} from "@solana/spl-token";
import { Market } from "@project-serum/serum";
import { SRM_MINT, USDC_MINT, USDT_MINT } from "../utils/pubkeys";
import { useFairRoute, useRouteVerbose, useDexContext } from "./Dex";
import {
useFairRoute,
useRouteVerbose,
useDexContext,
FEE_MULTIPLIER,
} from "./Dex";
import {
useTokenListContext,
SPL_REGISTRY_SOLLET_TAG,
SPL_REGISTRY_WORM_TAG,
} from "./TokenList";
import { useOwnedTokenAccount } from "../context/Token";
const DEFAULT_SLIPPAGE_PERCENT = 0.5;
@ -83,31 +89,40 @@ export function SwapContextProvider(props: any) {
assert.ok(slippage >= 0);
useEffect(() => {
if (!fair) {
return;
}
setFromAmount(fromAmount);
}, [fair]);
const swapToFromMints = () => {
const oldFrom = fromMint;
const oldFromAmount = fromAmount;
const oldTo = toMint;
const oldToAmount = toAmount;
_setFromAmount(oldToAmount);
setFromMint(oldTo);
setToMint(oldFrom);
_setFromAmount(oldToAmount);
_setToAmount(oldFromAmount);
};
const setFromAmount = (amount: number) => {
if (fair === undefined) {
throw new Error("Fair price not found");
_setFromAmount(0);
_setToAmount(0);
return;
}
_setFromAmount(amount);
_setToAmount(amount / fair);
_setToAmount(FEE_MULTIPLIER * (amount / fair));
};
const setToAmount = (amount: number) => {
if (fair === undefined) {
throw new Error("Fair price not found");
_setFromAmount(0);
_setToAmount(0);
return;
}
_setToAmount(amount);
_setFromAmount(amount * fair);
_setFromAmount((amount * fair) / FEE_MULTIPLIER);
};
return (
@ -166,12 +181,20 @@ export function useCanSwap(): boolean {
const { fromMint, toMint, fromAmount, toAmount } = useSwapContext();
const { swapClient } = useDexContext();
const { wormholeMap, solletMap } = useTokenListContext();
const fromWallet = useOwnedTokenAccount(fromMint);
const fair = useSwapFair();
const route = useRouteVerbose(fromMint, toMint);
if (route === null) {
return false;
}
return (
// From wallet exists.
fromWallet !== undefined &&
fromWallet !== null &&
// Fair price is defined.
fair !== undefined &&
fair > 0 &&
// Mints are distinct.
fromMint.equals(toMint) === false &&
// Wallet is connected.

View File

@ -1,7 +1,7 @@
import React, { useContext, useState, useEffect } from "react";
import * as assert from "assert";
import { useAsync } from "react-async-hook";
import { Provider } from "@project-serum/anchor";
import { Provider, BN } from "@project-serum/anchor";
import { PublicKey, Account } from "@solana/web3.js";
import {
MintInfo,
@ -9,7 +9,11 @@ import {
Token,
TOKEN_PROGRAM_ID,
} from "@solana/spl-token";
import { getOwnedTokenAccounts, parseTokenAccountData } from "../utils/tokens";
import {
getOwnedAssociatedTokenAccounts,
parseTokenAccountData,
} from "../utils/tokens";
import { SOL_MINT } from "../utils/pubkeys";
export type TokenContext = {
provider: Provider;
@ -27,14 +31,33 @@ export function TokenContextProvider(props: any) {
setRefresh((r) => r + 1);
return;
}
getOwnedTokenAccounts(provider.connection, provider.wallet.publicKey).then(
(accs) => {
if (accs) {
_OWNED_TOKEN_ACCOUNTS_CACHE.push(...accs);
// Fetch SPL tokens.
getOwnedAssociatedTokenAccounts(
provider.connection,
provider.wallet.publicKey
).then((accs) => {
if (accs) {
// @ts-ignore
_OWNED_TOKEN_ACCOUNTS_CACHE.push(...accs);
setRefresh((r) => r + 1);
}
});
// Fetch SOL balance.
provider.connection
.getAccountInfo(provider.wallet.publicKey)
.then((acc: { lamports: number }) => {
if (acc) {
_OWNED_TOKEN_ACCOUNTS_CACHE.push({
publicKey: provider.wallet.publicKey,
// @ts-ignore
account: {
amount: new BN(acc.lamports),
mint: SOL_MINT,
},
});
setRefresh((r) => r + 1);
}
}
);
});
}, [provider.wallet.publicKey, provider.connection]);
return (
@ -69,23 +92,28 @@ export function useOwnedTokenAccount(
// Take the account with the most tokens in it.
tokenAccounts.sort((a, b) =>
a.account.amount < b.account.amount
a.account.amount > b.account.amount
? -1
: a.account.amount > b.account.amount
: a.account.amount < b.account.amount
? 1
: 0
);
const tokenAccount = tokenAccounts[0];
let tokenAccount = tokenAccounts[0];
const isSol = mint?.equals(SOL_MINT);
// Stream updates when the balance changes.
useEffect(() => {
let listener: number;
if (tokenAccount) {
// SOL is special cased since it's not an SPL token.
if (tokenAccount && isSol) {
listener = provider.connection.onAccountChange(
tokenAccount.publicKey,
(info) => {
const token = parseTokenAccountData(info.data);
provider.wallet.publicKey,
(info: { lamports: number }) => {
const token = {
amount: new BN(info.lamports),
mint: SOL_MINT,
} as TokenAccount;
if (token.amount !== tokenAccount.account.amount) {
const index = _OWNED_TOKEN_ACCOUNTS_CACHE.indexOf(tokenAccount);
assert.ok(index >= 0);
@ -95,6 +123,27 @@ export function useOwnedTokenAccount(
}
);
}
// SPL tokens.
else if (tokenAccount) {
listener = provider.connection.onAccountChange(
tokenAccount.publicKey,
(info) => {
if (info.data.length !== 0) {
try {
const token = parseTokenAccountData(info.data);
if (token.amount !== tokenAccount.account.amount) {
const index = _OWNED_TOKEN_ACCOUNTS_CACHE.indexOf(tokenAccount);
assert.ok(index >= 0);
_OWNED_TOKEN_ACCOUNTS_CACHE[index].account = token;
setRefresh((r) => r + 1);
}
} catch (error) {
console.log("Failed to decode token AccountInfo");
}
}
}
);
}
return () => {
if (listener) {
provider.connection.removeAccountChangeListener(listener);
@ -106,7 +155,7 @@ export function useOwnedTokenAccount(
return undefined;
}
if (tokenAccounts.length === 0) {
if (!isSol && tokenAccounts.length === 0) {
return null;
}
@ -152,4 +201,7 @@ const _OWNED_TOKEN_ACCOUNTS_CACHE: Array<{
}> = [];
// Cache storing all previously fetched mint infos.
const _MINT_CACHE = new Map<string, Promise<MintInfo>>();
// @ts-ignore
const _MINT_CACHE = new Map<string, Promise<MintInfo>>([
[SOL_MINT.toString(), { decimals: 9 }],
]);

View File

@ -1,6 +1,6 @@
import React, { useContext, useMemo } from "react";
import { TokenInfo } from "@solana/spl-token-registry";
import { USDC_MINT, USDT_MINT } from "../utils/pubkeys";
import { SOL_MINT } from "../utils/pubkeys";
type TokenListContext = {
tokenMap: Map<string, TokenInfo>;
@ -18,11 +18,32 @@ export const SPL_REGISTRY_SOLLET_TAG = "wrapped-sollet";
// Tag in the spl-token-registry for wormhole wrapped tokens.
export const SPL_REGISTRY_WORM_TAG = "wormhole";
const SOL_TOKEN_INFO = {
chainId: 101,
address: SOL_MINT.toString(),
name: "Native SOL",
decimals: "9",
symbol: "SOL",
logoURI:
"https://cdn.jsdelivr.net/gh/trustwallet/assets@master/blockchains/solana/info/logo.png",
tags: [],
extensions: {
website: "https://solana.com/",
serumV3Usdc: "9wFFyRfZBsuAha4YcuxcXLKwMxJR43S7fPfQLusDBzvT",
serumV3Usdt: "HWHvQhFmJB3NUcu1aihKmrKegfVxBEHzwVX6yZCKEsi1",
coingeckoId: "solana",
waterfallbot: "https://t.me/SOLwaterfall",
},
};
export function TokenListContextProvider(props: any) {
const tokenList = useMemo(
() => props.tokenList.filterByClusterSlug("mainnet-beta").getList(),
[props.tokenList]
);
const tokenList = useMemo(() => {
const list = props.tokenList.filterByClusterSlug("mainnet-beta").getList();
// Manually add a fake SOL mint for the native token. The component is
// opinionated in that it distinguishes between wrapped SOL and SOL.
list.push(SOL_TOKEN_INFO);
return list;
}, [props.tokenList]);
// Token map for quick lookup.
const tokenMap = useMemo(() => {
@ -35,14 +56,11 @@ export function TokenListContextProvider(props: any) {
// Tokens with USD(x) quoted markets.
const swappableTokens = useMemo(() => {
const tokens = tokenList
.filter((t: TokenInfo) => {
const isUsdxQuoted =
t.extensions?.serumV3Usdt || t.extensions?.serumV3Usdc;
const isSol =
t.address === "So11111111111111111111111111111111111111112";
return isUsdxQuoted && !isSol;
})
const tokens = tokenList.filter((t: TokenInfo) => {
const isUsdxQuoted =
t.extensions?.serumV3Usdt || t.extensions?.serumV3Usdc;
return isUsdxQuoted;
});
tokens.sort((a: TokenInfo, b: TokenInfo) =>
a.symbol < b.symbol ? -1 : a.symbol > b.symbol ? 1 : 0
);

View File

@ -1 +0,0 @@
@import url("https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;700&display=swap");

View File

@ -1,19 +1,33 @@
import "@fontsource/roboto";
import { ReactElement } from "react";
import { PublicKey } from "@solana/web3.js";
import { TokenListContainer } from "@solana/spl-token-registry";
import { Provider } from "@project-serum/anchor";
import { Swap as SwapClient } from "@project-serum/swap";
import { SwapContextProvider } from "./context/Swap";
import { DexContextProvider } from "./context/Dex";
import { TokenListContextProvider } from "./context/TokenList";
import { TokenContextProvider } from "./context/Token";
import SwapCard from "./components/Swap";
import {
createMuiTheme,
ThemeOptions,
ThemeProvider,
} from "@material-ui/core/styles";
import {
SwapContextProvider,
useSwapContext,
useSwapFair,
} from "./context/Swap";
import {
DexContextProvider,
useBbo,
useFairRoute,
useMarketName,
} from "./context/Dex";
import { TokenListContextProvider, useTokenMap } from "./context/TokenList";
import { TokenContextProvider, useMint } from "./context/Token";
import SwapCard, {
ArrowButton,
SwapButton,
SwapHeader,
SwapTokenForm,
} from "./components/Swap";
import TokenDialog from "./components/TokenDialog";
/**
* A`Swap` component that can be embedded into applications. To use,
@ -30,7 +44,7 @@ import {
* For information on other properties like earning referrals, see the
* [[SwapProps]] documentation.
*/
export function Swap(props: SwapProps): ReactElement {
export default function Swap(props: SwapProps): ReactElement {
const {
containerStyle,
contentStyle,
@ -44,6 +58,8 @@ export function Swap(props: SwapProps): ReactElement {
toAmount,
referral,
} = props;
// @ts-ignore
const swapClient = new SwapClient(provider, tokenList);
const theme = createMuiTheme(
materialTheme || {
@ -153,4 +169,29 @@ export type SwapProps = {
swapTokenContainerStyle?: any;
};
export default Swap;
export {
// Components.
Swap,
SwapCard,
SwapHeader,
SwapTokenForm,
ArrowButton,
SwapButton,
TokenDialog,
// Providers and context.
// Swap.
SwapContextProvider,
useSwapContext,
useSwapFair,
// TokenList.
TokenListContextProvider,
useTokenMap,
// Token.
TokenContextProvider,
useMint,
// Dex.
DexContextProvider,
useFairRoute,
useMarketName,
useBbo,
};

View File

@ -16,6 +16,15 @@ export const USDT_MINT = new PublicKey(
"Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"
);
// Arbitrary mint to represent SOL (not wrapped SOL).
export const SOL_MINT = new PublicKey(
"Ejmc1UB4EsES5oAaRN63SpoxMJidt3ZGBrqrZk49vjTZ"
);
export const WRAPPED_SOL_MINT = new PublicKey(
"So11111111111111111111111111111111111111112"
);
export const WORM_MARKET_BASE = new PublicKey(
"6a9wpsZpZGxGhFVSQBpcTNjNjytdbSA1iUw1A5KNDxPw"
);

View File

@ -5,62 +5,58 @@ import * as BufferLayout from "buffer-layout";
import { BN } from "@project-serum/anchor";
import {
TOKEN_PROGRAM_ID,
ASSOCIATED_TOKEN_PROGRAM_ID,
Token,
AccountInfo as TokenAccount,
} from "@solana/spl-token";
import { Connection, PublicKey } from "@solana/web3.js";
import * as bs58 from "bs58";
export async function getOwnedTokenAccounts(
export async function getOwnedAssociatedTokenAccounts(
connection: Connection,
publicKey: PublicKey
) {
let filters = getOwnedAccountsFilters(publicKey);
// @ts-ignore
let resp = await connection._rpcRequest("getProgramAccounts", [
TOKEN_PROGRAM_ID.toBase58(),
{
commitment: connection.commitment,
filters,
},
]);
if (resp.error) {
throw new Error(
"failed to get token accounts owned by " +
publicKey.toBase58() +
": " +
resp.error.message
);
}
return resp.result
let resp = await connection.getProgramAccounts(TOKEN_PROGRAM_ID, {
commitment: connection.commitment,
filters,
});
const accs = resp
.map(({ pubkey, account: { data, executable, owner, lamports } }: any) => ({
publicKey: new PublicKey(pubkey),
accountInfo: {
data: bs58.decode(data),
data,
executable,
owner: new PublicKey(owner),
lamports,
},
}))
.filter(({ accountInfo }: any) => {
// TODO: remove this check once mainnet is updated
return filters.every((filter) => {
if (filter.dataSize) {
return accountInfo.data.length === filter.dataSize;
} else if (filter.memcmp) {
let filterBytes = bs58.decode(filter.memcmp.bytes);
return accountInfo.data
.slice(
filter.memcmp.offset,
filter.memcmp.offset + filterBytes.length
)
.equals(filterBytes);
}
return false;
});
})
.map(({ publicKey, accountInfo }: any) => {
return { publicKey, account: parseTokenAccountData(accountInfo.data) };
});
return (
(
await Promise.all(
accs
// @ts-ignore
.map(async (ta) => {
const ata = await Token.getAssociatedTokenAddress(
ASSOCIATED_TOKEN_PROGRAM_ID,
TOKEN_PROGRAM_ID,
ta.account.mint,
publicKey
);
return [ta, ata];
})
)
)
// @ts-ignore
.filter(([ta, ata]) => ta.publicKey.equals(ata))
// @ts-ignore
.map(([ta]) => ta)
);
}
const ACCOUNT_LAYOUT = BufferLayout.struct([

View File

@ -13,7 +13,7 @@
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"module": "commonjs",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,

3866
yarn.lock

File diff suppressed because it is too large Load Diff