diff --git a/src/swap/components/TokenDialog.tsx b/src/swap/components/TokenDialog.tsx
index 95b3d26..5d5ddf3 100644
--- a/src/swap/components/TokenDialog.tsx
+++ b/src/swap/components/TokenDialog.tsx
@@ -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("");
}}
/>
))}
diff --git a/src/swap/components/context/Dex.tsx b/src/swap/components/context/Dex.tsx
index 578c6a9..85d3220 100644
--- a/src/swap/components/context/Dex.tsx
+++ b/src/swap/components/context/Dex.tsx
@@ -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
| 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([
+ [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,
+ solletMap: Map
+): Promise {
+ 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,
+ solletMap: Map
+): Promise {
+ 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 {
+ 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);
+}
diff --git a/src/swap/components/context/Sollet.tsx b/src/swap/components/context/Sollet.tsx
new file mode 100644
index 0000000..aa38d48
--- /dev/null
+++ b/src/swap/components/context/Sollet.tsx
@@ -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 {
+ return useAsync(async () => {
+ return fetchSolletInfo(mint);
+ }, [mint]);
+}
+
+// Fetches the token info from the sollet bridge.
+export async function fetchSolletInfo(mint: PublicKey): Promise {
+ 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 {
+ 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();
+const _SWAP_MARKET_EXISTS_CACHE = new Map();
diff --git a/src/swap/components/context/TokenList.tsx b/src/swap/components/context/TokenList.tsx
index e50bdfa..7bdfefd 100644
--- a/src/swap/components/context/TokenList.tsx
+++ b/src/swap/components/context/TokenList.tsx
@@ -4,6 +4,8 @@ import { USDC_MINT, USDT_MINT } from "../../utils/pubkeys";
type TokenListContext = {
tokenMap: Map;
+ wormholeMap: Map;
+ solletMap: Map;
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(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(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");
diff --git a/src/swap/utils/pubkeys.ts b/src/swap/utils/pubkeys.ts
index c47226e..9df69e8 100644
--- a/src/swap/utils/pubkeys.ts
+++ b/src/swap/utils/pubkeys.ts
@@ -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"
);