diff --git a/src/App.tsx b/src/App.tsx index f8fc76f..668ec6c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -152,7 +152,7 @@ class NotifyingProvider extends Provider { return txSig; } catch (err) { this.onTransaction(undefined, err); - throw err; + return ""; } } } diff --git a/src/swap/components/Info.tsx b/src/swap/components/Info.tsx index 197218f..a4331f7 100644 --- a/src/swap/components/Info.tsx +++ b/src/swap/components/Info.tsx @@ -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 (
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" );