Wormhole swap market routes

This commit is contained in:
armaniferrante 2021-05-16 01:02:33 -07:00
parent 726a832524
commit 6aa831d298
No known key found for this signature in database
GPG Key ID: 58BEF301E91F7828
7 changed files with 327 additions and 21 deletions

View File

@ -152,7 +152,7 @@ class NotifyingProvider extends Provider {
return txSig;
} catch (err) {
this.onTransaction(undefined, err);
throw err;
return "";
}
}
}

View File

@ -11,7 +11,7 @@ import { PublicKey } from "@solana/web3.js";
import { useTokenMap } from "./context/TokenList";
import { useSwapContext, useSwapFair } from "./context/Swap";
import { useMint } from "./context/Mint";
import { useDexContext, useMarketName, useFair } from "./context/Dex";
import { useRoute, useMarketName, useFair } from "./context/Dex";
const useStyles = makeStyles((theme) => ({
infoLabel: {
@ -100,7 +100,7 @@ function InfoButton() {
function InfoDetails() {
const { fromMint, toMint } = useSwapContext();
const { swapClient } = useDexContext();
const route = useRoute(fromMint, toMint);
const tokenMap = useTokenMap();
const fromMintTicker = tokenMap.get(fromMint.toString())?.symbol;
const toMintTicker = tokenMap.get(toMint.toString())?.symbol;
@ -108,7 +108,7 @@ function InfoDetails() {
{ ticker: fromMintTicker, mint: fromMint },
{ ticker: toMintTicker, mint: toMint },
];
const route = swapClient.route(fromMint, toMint);
return (
<div style={{ padding: "15px", width: "250px" }}>
<div>

View File

@ -20,6 +20,7 @@ import { useSwappableTokens } from "./context/TokenList";
const useStyles = makeStyles(() => ({
dialogContent: {
paddingTop: 0,
paddingBottom: 0,
},
textField: {
width: "100%",
@ -98,7 +99,6 @@ export default function TokenDialog({
onClick={(mint) => {
setMint(mint);
onClose();
setTokenFilter("");
}}
/>
))}

View File

@ -1,5 +1,7 @@
import React, { useContext, useState, useEffect, useMemo } from "react";
import React, { useContext, useState, useEffect } from "react";
import { useAsync } from "react-async-hook";
import { TokenInfo } from "@solana/spl-token-registry";
import { Connection, PublicKey } from "@solana/web3.js";
import * as anchor from "@project-serum/anchor";
import { Swap as SwapClient } from "@project-serum/swap";
import {
@ -7,9 +9,18 @@ import {
OpenOrders,
Orderbook as OrderbookSide,
} from "@project-serum/serum";
import { PublicKey } from "@solana/web3.js";
import { DEX_PID } from "../../utils/pubkeys";
import { useTokenMap } from "./TokenList";
import {
DEX_PID,
USDC_MINT,
USDT_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";
type DexContext = {
// Maps market address to open orders accounts.
@ -195,9 +206,12 @@ export function useOrderbook(market?: PublicKey): Orderbook | undefined {
return undefined;
}
export function useMarketName(market: PublicKey): string {
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
: "-";
@ -216,6 +230,12 @@ export function useFair(market?: PublicKey): number | undefined {
}
const bestBid = orderbook.bids.items(true).next().value;
const bestOffer = orderbook.asks.items(false).next().value;
if (!bestBid) {
return bestOffer.price;
}
if (!bestOffer) {
return bestBid.price;
}
const mid = (bestBid.price + bestOffer.price) / 2.0;
return mid;
}
@ -252,18 +272,166 @@ export function useFairRoute(
return toFair / fromFair;
}
// 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 useRoute(
fromMint: PublicKey,
toMint: PublicKey
): Array<PublicKey> | null {
const { swapClient } = useDexContext();
return useMemo(
() => swapClient.route(fromMint, toMint),
[swapClient, fromMint, toMint]
);
const { wormholeMap, solletMap } = useTokenListContext();
const asyncRoute = useAsync(async () => {
const wormholeMarket = await wormholeSwapMarket(
swapClient.program.provider.connection,
fromMint,
toMint,
wormholeMap,
solletMap
);
if (wormholeMarket !== null) {
return [wormholeMarket];
}
return swapClient.route(fromMint, toMint);
}, [fromMint, toMint, swapClient]);
if (asyncRoute.result) {
return asyncRoute.result;
}
return null;
}
type Orderbook = {
bids: OrderbookSide;
asks: OrderbookSide;
};
// Wormhole utils.
// 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();
}
function wormholeNativeMarket(
fromMint: PublicKey,
toMint: PublicKey
): PublicKey | undefined {
return WORMHOLE_NATIVE_MAP.get(wormKey(fromMint, toMint));
}
async function wormholeSwapMarket(
conn: Connection,
fromMint: PublicKey,
toMint: PublicKey,
wormholeMap: Map<string, TokenInfo>,
solletMap: Map<string, TokenInfo>
): Promise<PublicKey | null> {
let market = wormholeNativeMarket(fromMint, toMint);
if (market !== undefined) {
return market;
}
return await wormholeSolletMarket(
conn,
fromMint,
toMint,
wormholeMap,
solletMap
);
}
// Returns the market address of the 1-1 sollet<->wormhole swap market if it
// exists. Otherwise, returns null.
//
// TODO: swap transactions dont work for wormhole yet, since the client
// doesnt do any wormhole checks.
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);
}

View File

@ -0,0 +1,100 @@
import { useAsync, UseAsyncReturn } from "react-async-hook";
import { Connection, PublicKey } from "@solana/web3.js";
// Token info tracked by the sollet bridge.
type SolletInfo = {
blockchain: string;
erc20Contract: string;
name: string;
splMint: PublicKey;
ticker: string;
};
export function useSolletInfo(mint: PublicKey): UseAsyncReturn<SolletInfo> {
return useAsync(async () => {
return fetchSolletInfo(mint);
}, [mint]);
}
// Fetches the token info from the sollet bridge.
export async function fetchSolletInfo(mint: PublicKey): Promise<SolletInfo> {
let info = _SOLLET_INFO_CACHE.get(mint.toString());
if (info !== undefined) {
return info;
}
const infoRaw = await swapApiRequest("GET", `coins/sol/${mint.toString()}`);
info = { ...infoRaw, splMint: new PublicKey(infoRaw.splMint) };
_SOLLET_INFO_CACHE.set(mint.toString(), info!);
return info!;
}
// Requests the creation of a sollet wormhole swap market, if it doesn't
// already exist. Note: this triggers a creation notification. Creation
// doesn't happen immediately, but at some unspecified point in the future
// since market makers need to setup on the swap market and provide liquidity.
//
// Returns true if the market exists already. False otherwise.
export async function requestWormholeSwapMarketIfNeeded(
connection: Connection,
solletMint: PublicKey,
wormholeMint: PublicKey,
swapMarket: PublicKey,
solletInfo: SolletInfo
): Promise<boolean> {
const cached = _SWAP_MARKET_EXISTS_CACHE.get(swapMarket.toString());
if (cached !== undefined) {
return cached;
}
const acc = await connection.getAccountInfo(swapMarket);
if (acc === null) {
_SWAP_MARKET_EXISTS_CACHE.set(swapMarket.toString(), false);
const resource = `wormhole/pool/${
solletInfo.ticker
}/${swapMarket.toString()}/${solletMint.toString()}/${wormholeMint.toString()}`;
swapApiRequest("POST", resource).catch(console.error);
return false;
} else {
_SWAP_MARKET_EXISTS_CACHE.set(swapMarket.toString(), true);
return true;
}
}
export async function swapApiRequest(
method: string,
path: string,
body?: Object
) {
let headers: any = {};
let params: any = { headers, method };
if (method === "GET") {
params.cache = "no-cache";
} else if (body) {
headers["Content-Type"] = "application/json";
params.body = JSON.stringify(body);
}
let resp = await fetch(`https://swap.sollet.io/api/${path}`, params);
return await handleSwapApiResponse(resp);
}
async function handleSwapApiResponse(resp: Response) {
let json = await resp.json();
if (!json.success) {
throw new SwapApiError(json.error, resp.status);
}
return json.result;
}
export class SwapApiError extends Error {
readonly name: string;
readonly status: number;
constructor(msg: string, status: number) {
super(msg);
this.name = "SwapApiError";
this.status = status;
}
}
const _SOLLET_INFO_CACHE = new Map<string, SolletInfo>();
const _SWAP_MARKET_EXISTS_CACHE = new Map<string, boolean>();

View File

@ -4,6 +4,8 @@ import { USDC_MINT, USDT_MINT } from "../../utils/pubkeys";
type TokenListContext = {
tokenMap: Map<string, TokenInfo>;
wormholeMap: Map<string, TokenInfo>;
solletMap: Map<string, TokenInfo>;
swappableTokens: TokenInfo[];
swappableTokensSollet: TokenInfo[];
swappableTokensWormhole: TokenInfo[];
@ -15,6 +17,8 @@ export function TokenListContextProvider(props: any) {
() => props.tokenList.filterByClusterSlug("mainnet-beta").getList(),
[props.tokenList]
);
// Token map for quick lookup.
const tokenMap = useMemo(() => {
const tokenMap = new Map();
tokenList.forEach((t: TokenInfo) => {
@ -22,6 +26,8 @@ export function TokenListContextProvider(props: any) {
});
return tokenMap;
}, [tokenList]);
// Tokens with USD(x) quoted markets.
const swappableTokens = useMemo(() => {
const tokens = tokenList
.filter((t: TokenInfo) => {
@ -40,7 +46,9 @@ export function TokenListContextProvider(props: any) {
);
return tokens;
}, [tokenList, tokenMap]);
const swappableTokensSollet = useMemo(() => {
// Sollet wrapped tokens.
const [swappableTokensSollet, solletMap] = useMemo(() => {
const tokens = tokenList.filter((t: TokenInfo) => {
const isSollet = t.tags?.includes("wrapped-sollet");
return isSollet;
@ -48,9 +56,14 @@ export function TokenListContextProvider(props: any) {
tokens.sort((a: TokenInfo, b: TokenInfo) =>
a.symbol < b.symbol ? -1 : a.symbol > b.symbol ? 1 : 0
);
return tokens;
return [
tokens,
new Map<string, TokenInfo>(tokens.map((t: TokenInfo) => [t.address, t])),
];
}, [tokenList]);
const swappableTokensWormhole = useMemo(() => {
// Wormhole wrapped tokens.
const [swappableTokensWormhole, wormholeMap] = useMemo(() => {
const tokens = tokenList.filter((t: TokenInfo) => {
const isSollet = t.tags?.includes("wormhole");
return isSollet;
@ -58,13 +71,18 @@ export function TokenListContextProvider(props: any) {
tokens.sort((a: TokenInfo, b: TokenInfo) =>
a.symbol < b.symbol ? -1 : a.symbol > b.symbol ? 1 : 0
);
return tokens;
return [
tokens,
new Map<string, TokenInfo>(tokens.map((t: TokenInfo) => [t.address, t])),
];
}, [tokenList]);
return (
<_TokenListContext.Provider
value={{
tokenMap,
wormholeMap,
solletMap,
swappableTokens,
swappableTokensWormhole,
swappableTokensSollet,
@ -75,7 +93,7 @@ export function TokenListContextProvider(props: any) {
);
}
function useTokenListContext(): TokenListContext {
export function useTokenListContext(): TokenListContext {
const ctx = useContext(_TokenListContext);
if (ctx === null) {
throw new Error("Context not available");

View File

@ -1,5 +1,9 @@
import { PublicKey } from "@solana/web3.js";
export const DEX_PID = new PublicKey(
"9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin"
);
export const SRM_MINT = new PublicKey(
"SRMuApVNdxXokk5GT7XD5cUUgXMBCoAz2LHeuAoKWRt"
);
@ -12,6 +16,22 @@ export const USDT_MINT = new PublicKey(
"Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"
);
export const DEX_PID = new PublicKey(
"9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin"
export const WORM_MARKET_BASE = new PublicKey(
"6a9wpsZpZGxGhFVSQBpcTNjNjytdbSA1iUw1A5KNDxPw"
);
export const WORM_USDC_MINT = new PublicKey(
"FVsXUnbhifqJ4LiXQEbpUtXVdB8T5ADLKqSs5t1oc54F"
);
export const WORM_USDC_MARKET = new PublicKey(
"6nGMps9VfDjkKEwYjdSNqN1ToXkLae4VsN49fzBiDFBd"
);
export const WORM_USDT_MINT = new PublicKey(
"9w97GdWUYYaamGwdKMKZgGzPduZJkiFizq4rz5CPXRv2"
);
export const WORM_USDT_MARKET = new PublicKey(
"4v6e6vNXAaEunrvbqkYnKwbaWfck8a2KVR4uRAVXxVwC"
);