swap-ui/src/context/Dex.tsx

610 lines
18 KiB
TypeScript

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";
import { Connection, PublicKey } from "@solana/web3.js";
import * as anchor from "@project-serum/anchor";
import { Swap as SwapClient } from "@project-serum/swap";
import {
Market,
OpenOrders,
Orderbook as OrderbookSide,
} from "@project-serum/serum";
import {
DEX_PID,
USDC_MINT,
USDT_MINT,
SOL_MINT,
WRAPPED_SOL_MINT,
WORM_USDC_MINT,
WORM_USDT_MINT,
WORM_USDC_MARKET,
WORM_USDT_MARKET,
WORM_MARKET_BASE,
} from "../utils/pubkeys";
import { useTokenMap, useTokenListContext } from "./TokenList";
import { fetchSolletInfo, requestWormholeSwapMarketIfNeeded } from "./Sollet";
import { setMintCache } from "./Token";
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.
openOrders: Map<string, Array<OpenOrders>>;
closeOpenOrders: (openOrder: OpenOrders) => void;
swapClient: SwapClient;
};
const _DexContext = React.createContext<DexContext | null>(null);
export function DexContextProvider(props: any) {
const [ooAccounts, setOoAccounts] = useState<Map<string, Array<OpenOrders>>>(
new Map()
);
const swapClient = props.swapClient;
// Removes the given open orders from the context.
const closeOpenOrders = async (openOrder: OpenOrders) => {
const newOoAccounts = new Map(ooAccounts);
const openOrders = newOoAccounts
.get(openOrder.market.toString())
?.filter((oo: OpenOrders) => !oo.address.equals(openOrder.address));
if (openOrders && openOrders.length > 0) {
newOoAccounts.set(openOrder.market.toString(), openOrders);
} else {
newOoAccounts.delete(openOrder.market.toString());
}
setOoAccounts(newOoAccounts);
};
// Three operations:
//
// 1. Fetch all open orders accounts for the connected wallet.
// 2. Batch fetch all market accounts for those open orders.
// 3. Batch fetch all mints associated with the markets.
useEffect(() => {
if (!swapClient.program.provider.wallet.publicKey) {
setOoAccounts(new Map());
return;
}
OpenOrders.findForOwner(
swapClient.program.provider.connection,
swapClient.program.provider.wallet.publicKey,
DEX_PID
).then(async (openOrders) => {
const newOoAccounts = new Map();
let markets = new Set<string>();
openOrders.forEach((oo) => {
markets.add(oo.market.toString());
if (newOoAccounts.get(oo.market.toString())) {
newOoAccounts.get(oo.market.toString()).push(oo);
} else {
newOoAccounts.set(oo.market.toString(), [oo]);
}
});
if (markets.size > 100) {
// Punt request chunking until there's user demand.
throw new Error(
"Too many markets. Please file an issue to update this"
);
}
const multipleMarkets = await anchor.utils.rpc.getMultipleAccounts(
swapClient.program.provider.connection,
Array.from(markets.values()).map((m) => new PublicKey(m))
);
const marketClients = multipleMarkets.map((programAccount) => {
return {
publicKey: programAccount?.publicKey,
account: new Market(
Market.getLayout(DEX_PID).decode(programAccount?.account.data),
-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
),
};
});
setOoAccounts(newOoAccounts);
// Batch fetch all the mints, since we know we'll need them at some
// point.
const mintPubkeys = Array.from(
new Set<string>(
marketClients
.map((m) => [
m.account.baseMintAddress.toString(),
m.account.quoteMintAddress.toString(),
])
.flat()
).values()
).map((pk) => new PublicKey(pk));
if (mintPubkeys.length > 100) {
// Punt request chunking until there's user demand.
throw new Error("Too many mints. Please file an issue to update this");
}
const mints = await anchor.utils.rpc.getMultipleAccounts(
swapClient.program.provider.connection,
mintPubkeys
);
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))
);
});
});
}, [
swapClient.program.provider.connection,
swapClient.program.provider.wallet.publicKey,
swapClient.program.provider.opts,
]);
return (
<_DexContext.Provider
value={{
openOrders: ooAccounts,
closeOpenOrders,
swapClient,
}}
>
{props.children}
</_DexContext.Provider>
);
}
export function useDexContext(): DexContext {
const ctx = useContext(_DexContext);
if (ctx === null) {
throw new Error("Context not available");
}
return ctx;
}
export function useOpenOrders(): Map<string, Array<OpenOrders>> {
const ctx = useDexContext();
return ctx.openOrders;
}
// Lazy load a given market.
export function useMarket(market?: PublicKey): Market | undefined {
const { swapClient } = useDexContext();
const asyncMarket = useAsync(async () => {
if (!market) {
return undefined;
}
if (_MARKET_CACHE.get(market.toString())) {
return _MARKET_CACHE.get(market.toString());
}
const marketClient = new Promise<Market>(async (resolve) => {
// 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
);
resolve(marketClient);
});
_MARKET_CACHE.set(market.toString(), marketClient);
return marketClient;
}, [swapClient.program.provider.connection, market]);
if (asyncMarket.result) {
return asyncMarket.result;
}
return undefined;
}
// Lazy load the orderbook for a given market.
export function useOrderbook(market?: PublicKey): Orderbook | undefined {
const { swapClient } = useDexContext();
const marketClient = useMarket(market);
const [refresh, setRefresh] = useState(0);
const asyncOrderbook = useAsync(async () => {
if (!market || !marketClient) {
return undefined;
}
if (_ORDERBOOK_CACHE.get(market.toString())) {
return _ORDERBOOK_CACHE.get(market.toString());
}
const orderbook = new Promise<Orderbook>(async (resolve) => {
const [bids, asks] = await Promise.all([
marketClient.loadBids(swapClient.program.provider.connection),
marketClient.loadAsks(swapClient.program.provider.connection),
]);
resolve({
bids,
asks,
});
});
_ORDERBOOK_CACHE.set(market.toString(), orderbook);
return orderbook;
}, [refresh, swapClient.program.provider.connection, market, marketClient]);
// Stream in bids updates.
useEffect(() => {
let listener: number | undefined;
if (marketClient?.bidsAddress) {
listener = swapClient.program.provider.connection.onAccountChange(
marketClient?.bidsAddress,
async (info) => {
const bids = OrderbookSide.decode(marketClient, info.data);
const orderbook = await _ORDERBOOK_CACHE.get(
marketClient.address.toString()
);
const oldBestBid = orderbook?.bids.items(true).next().value;
const newBestBid = bids.items(true).next().value;
if (
orderbook &&
oldBestBid &&
newBestBid &&
oldBestBid.price !== newBestBid.price
) {
orderbook.bids = bids;
setRefresh((r) => r + 1);
}
}
);
}
return () => {
if (listener) {
swapClient.program.provider.connection.removeAccountChangeListener(
listener
);
}
};
}, [
marketClient,
marketClient?.bidsAddress,
swapClient.program.provider.connection,
]);
// Stream in asks updates.
useEffect(() => {
let listener: number | undefined;
if (marketClient?.asksAddress) {
listener = swapClient.program.provider.connection.onAccountChange(
marketClient?.asksAddress,
async (info) => {
const asks = OrderbookSide.decode(marketClient, info.data);
const orderbook = await _ORDERBOOK_CACHE.get(
marketClient.address.toString()
);
const oldBestOffer = orderbook?.asks.items(false).next().value;
const newBestOffer = asks.items(false).next().value;
if (
orderbook &&
oldBestOffer &&
newBestOffer &&
oldBestOffer.price !== newBestOffer.price
) {
orderbook.asks = asks;
setRefresh((r) => r + 1);
}
}
);
}
return () => {
if (listener) {
swapClient.program.provider.connection.removeAccountChangeListener(
listener
);
}
};
}, [
marketClient,
marketClient?.bidsAddress,
swapClient.program.provider.connection,
]);
if (asyncOrderbook.result) {
return asyncOrderbook.result;
}
return undefined;
}
export function useMarketName(market: PublicKey): string | null {
const tokenMap = useTokenMap();
const marketClient = useMarket(market);
if (!marketClient) {
return null;
}
const baseTicker = marketClient
? tokenMap.get(marketClient?.baseMintAddress.toString())?.symbol
: "-";
const quoteTicker = marketClient
? tokenMap.get(marketClient?.quoteMintAddress.toString())?.symbol
: "-";
const name = `${baseTicker} / ${quoteTicker}`;
return name;
}
// Fair price for a given market, as defined by the mid.
export function useBbo(market?: PublicKey): Bbo | undefined {
const orderbook = useOrderbook(market);
if (orderbook === undefined) {
return 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 };
}
if (!bestOffer) {
return { bestBid: bestBid.price };
}
const mid = (bestBid.price + bestOffer.price) / 2.0;
return { bestBid: bestBid.price, bestOffer: bestOffer.price, mid };
}
// Fair price for a theoretical toMint/fromMint market. I.e., the number
// of `fromMint` tokens to purchase a single `toMint` token. Aggregates
// across a trade route, if needed.
export function useFairRoute(
fromMint: PublicKey,
toMint: PublicKey
): number | undefined {
const route = useRoute(fromMint, toMint);
const fromBbo = useBbo(route ? route[0] : undefined);
const fromMarket = useMarket(route ? route[0] : undefined);
const toBbo = useBbo(route ? route[1] : undefined);
if (route === null) {
return undefined;
}
if (route.length === 1 && fromBbo !== undefined) {
if (fromMarket === undefined) {
return undefined;
}
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;
}
}
if (
fromBbo === undefined ||
fromBbo.bestBid === undefined ||
toBbo === undefined ||
toBbo.bestOffer === undefined
) {
return undefined;
}
return toBbo.bestOffer / fromBbo.bestBid;
}
export function useRoute(
fromMint: PublicKey,
toMint: PublicKey
): Array<PublicKey> | null {
const route = useRouteVerbose(fromMint, toMint);
if (route === null) {
return null;
}
return route.markets;
}
// Types of routes.
//
// 1. Direct trades on USDC quoted markets.
// 2. Transitive trades across two USDC qutoed markets.
// 3. Wormhole <-> Sollet one-to-one swap markets.
// 4. Wormhole <-> Native one-to-one swap markets.
//
export function useRouteVerbose(
fromMint: PublicKey,
toMint: PublicKey
): { markets: Array<PublicKey>; kind: RouteKind } | null {
const { swapClient } = useDexContext();
const { wormholeMap, solletMap } = useTokenListContext();
const asyncRoute = useAsync(async () => {
const swapMarket = await wormholeSwapMarket(
swapClient.program.provider.connection,
fromMint,
toMint,
wormholeMap,
solletMap
);
if (swapMarket !== null) {
const [wormholeMarket, kind] = swapMarket;
return { markets: [wormholeMarket], kind };
}
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;
}
const kind: RouteKind = "usdx";
return { markets, kind };
}, [fromMint, toMint, swapClient]);
if (asyncRoute.result) {
return asyncRoute.result;
}
return null;
}
type Orderbook = {
bids: OrderbookSide;
asks: OrderbookSide;
};
// Wormhole utils.
type RouteKind = "wormhole-native" | "wormhole-sollet" | "usdx";
// Maps fromMint || toMint (in sort order) to swap market public key.
// All markets for wormhole<->native tokens should be here, e.g.
// USDC <-> wUSDC.
const WORMHOLE_NATIVE_MAP = new Map<string, PublicKey>([
[wormKey(WORM_USDC_MINT, USDC_MINT), WORM_USDC_MARKET],
[wormKey(WORM_USDT_MINT, USDT_MINT), WORM_USDT_MARKET],
]);
function wormKey(fromMint: PublicKey, toMint: PublicKey): string {
const [first, second] =
fromMint < toMint ? [fromMint, toMint] : [toMint, fromMint];
return first.toString() + second.toString();
}
async function wormholeSwapMarket(
conn: Connection,
fromMint: PublicKey,
toMint: PublicKey,
wormholeMap: Map<string, TokenInfo>,
solletMap: Map<string, TokenInfo>
): Promise<[PublicKey, RouteKind] | null> {
let market = wormholeNativeMarket(fromMint, toMint);
if (market !== null) {
return [market, "wormhole-native"];
}
market = await wormholeSolletMarket(
conn,
fromMint,
toMint,
wormholeMap,
solletMap
);
if (market === null) {
return null;
}
return [market, "wormhole-sollet"];
}
function wormholeNativeMarket(
fromMint: PublicKey,
toMint: PublicKey
): PublicKey | null {
return WORMHOLE_NATIVE_MAP.get(wormKey(fromMint, toMint)) ?? null;
}
// Returns the market address of the 1-1 sollet<->wormhole swap market if it
// exists. Otherwise, returns null.
async function wormholeSolletMarket(
conn: Connection,
fromMint: PublicKey,
toMint: PublicKey,
wormholeMap: Map<string, TokenInfo>,
solletMap: Map<string, TokenInfo>
): Promise<PublicKey | null> {
const fromWormhole = wormholeMap.get(fromMint.toString());
const isFromWormhole = fromWormhole !== undefined;
const toWormhole = wormholeMap.get(toMint.toString());
const isToWormhole = toWormhole !== undefined;
const fromSollet = solletMap.get(fromMint.toString());
const isFromSollet = fromSollet !== undefined;
const toSollet = solletMap.get(toMint.toString());
const isToSollet = toSollet !== undefined;
if ((isFromWormhole || isToWormhole) && isFromWormhole !== isToWormhole) {
if ((isFromSollet || isToSollet) && isFromSollet !== isToSollet) {
const base = isFromSollet ? fromMint : toMint;
const [quote, wormholeInfo] = isFromWormhole
? [fromMint, fromWormhole]
: [toMint, toWormhole];
const solletInfo = await fetchSolletInfo(base);
if (solletInfo.erc20Contract !== wormholeInfo!.extensions?.address) {
return null;
}
const market = await deriveWormholeMarket(base, quote);
if (market === null) {
return null;
}
const marketExists = await requestWormholeSwapMarketIfNeeded(
conn,
base,
quote,
market,
solletInfo
);
if (!marketExists) {
return null;
}
return market;
}
}
return null;
}
// Calculates the deterministic address for the sollet<->wormhole 1-1 swap
// market.
async function deriveWormholeMarket(
baseMint: PublicKey,
quoteMint: PublicKey,
version = 0
): Promise<PublicKey | null> {
if (version > 99) {
console.log("Swap market version cannot be greater than 99");
return null;
}
if (version < 0) {
console.log("Version cannot be less than zero");
return null;
}
const padToTwo = (n: number) => (n <= 99 ? `0${n}`.slice(-2) : n);
const seed =
baseMint.toString().slice(0, 15) +
quoteMint.toString().slice(0, 15) +
padToTwo(version);
return await PublicKey.createWithSeed(WORM_MARKET_BASE, seed, DEX_PID);
}
type Bbo = {
bestBid?: number;
bestOffer?: number;
mid?: number;
};
const _ORDERBOOK_CACHE = new Map<string, Promise<Orderbook>>();
const _MARKET_CACHE = new Map<string, Promise<Market>>();