omega/ui/src/context/market.tsx

631 lines
17 KiB
TypeScript

import React, { useCallback, useContext, useEffect, useState } from "react";
import { POOLS_WITH_AIRDROP } from "./../models/airdrops";
import { MINT_TO_MARKET } from "./../models/marketOverrides";
import {
convert,
getPoolName,
getTokenName,
KnownTokenMap,
STABLE_COINS,
} from "./../utils/utils";
import { useConnectionConfig } from "./../utils/connection";
import {
cache,
getMultipleAccounts,
MintParser,
ParsedAccountBase,
useCachedPool,
} from "./../utils/accounts";
import { Market, MARKETS, Orderbook, TOKEN_MINTS } from "@project-serum/serum";
import { AccountInfo, Connection, PublicKey } from "@solana/web3.js";
import { useMemo } from "react";
import { PoolInfo } from "../models";
import { EventEmitter } from "./../utils/eventEmitter";
import { LIQUIDITY_PROVIDER_FEE, SERUM_FEE } from "../utils/pools";
interface RecentPoolData {
pool_identifier: string;
volume24hA: number;
}
export interface MarketsContextState {
midPriceInUSD: (mint: string) => number;
marketEmitter: EventEmitter;
accountsToObserve: Map<string, number>;
marketByMint: Map<string, SerumMarket>;
subscribeToMarket: (mint: string) => () => void;
dailyVolume: Map<string, RecentPoolData>;
}
const INITAL_LIQUIDITY_DATE = new Date("2020-10-27");
const REFRESH_INTERVAL = 30_000;
const BONFIDA_POOL_INTERVAL = 30 * 60_000; // 30 min
const MarketsContext = React.createContext<MarketsContextState | null>(null);
const marketEmitter = new EventEmitter();
export function MarketProvider({ children = null as any }) {
const { endpoint } = useConnectionConfig();
const { pools } = useCachedPool();
const accountsToObserve = useMemo(() => new Map<string, number>(), []);
const [dailyVolume, setDailyVolume] = useState<Map<string, RecentPoolData>>(
new Map()
);
const connection = useMemo(() => new Connection(endpoint, "recent"), [
endpoint,
]);
const marketByMint = useMemo(() => {
return [
...new Set(pools.map((p) => p.pubkeys.holdingMints).flat()).values(),
].reduce((acc, key) => {
const mintAddress = key.toBase58();
const SERUM_TOKEN = TOKEN_MINTS.find(
(a) => a.address.toBase58() === mintAddress
);
const marketAddress = MINT_TO_MARKET[mintAddress];
const marketName = `${SERUM_TOKEN?.name}/USDC`;
const marketInfo = MARKETS.find(
(m) => m.name === marketName || m.address.toBase58() === marketAddress
);
if (marketInfo) {
acc.set(mintAddress, {
marketInfo,
});
}
return acc;
}, new Map<string, SerumMarket>()) as Map<string, SerumMarket>;
}, [pools]);
useEffect(() => {
let timer = 0;
let bonfidaTimer = 0;
const updateData = async () => {
await refreshAccounts(connection, [...accountsToObserve.keys()]);
marketEmitter.raiseMarketUpdated(new Set([...marketByMint.keys()]));
timer = window.setTimeout(() => updateData(), REFRESH_INTERVAL);
};
const bonfidaQuery = async () => {
try {
const resp = await window.fetch(
"https://serum-api.bonfida.com/pools-recent"
);
const data = await resp.json();
const map = (data?.data as RecentPoolData[]).reduce((acc, item) => {
acc.set(item.pool_identifier, item);
return acc;
}, new Map<string, RecentPoolData>());
setDailyVolume(map);
} catch {
// ignore
}
bonfidaTimer = window.setTimeout(
() => bonfidaQuery(),
BONFIDA_POOL_INTERVAL
);
};
const initalQuery = async () => {
const reverseSerumMarketCache = new Map<string, string>();
[...marketByMint.keys()].forEach((mint) => {
const m = marketByMint.get(mint);
if (m) {
reverseSerumMarketCache.set(m.marketInfo.address.toBase58(), mint);
}
});
const allMarkets = [...marketByMint.values()].map((m) => {
return m.marketInfo.address.toBase58();
});
await getMultipleAccounts(
connection,
// only query for markets that are not in cahce
allMarkets.filter((a) => cache.get(a) === undefined),
"single"
).then(({ keys, array }) => {
allMarkets.forEach(() => {});
return array.map((item, index) => {
const marketAddress = keys[index];
const mintAddress = reverseSerumMarketCache.get(marketAddress);
if (mintAddress) {
const market = marketByMint.get(mintAddress);
if (market) {
const programId = market.marketInfo.programId;
const id = market.marketInfo.address;
cache.add(id, item, (id, acc) => {
const decoded = Market.getLayout(programId).decode(acc.data);
const details = {
pubkey: id,
account: {
...acc,
},
info: decoded,
} as ParsedAccountBase;
cache.registerParser(details.info.baseMint, MintParser);
cache.registerParser(details.info.quoteMint, MintParser);
cache.registerParser(details.info.bids, OrderBookParser);
cache.registerParser(details.info.asks, OrderBookParser);
return details;
});
}
}
return item;
});
});
const toQuery = new Set<string>();
allMarkets.forEach((m) => {
const market = cache.get(m);
if (!market) {
return;
}
const decoded = market;
if (!cache.get(decoded.info.baseMint)) {
toQuery.add(decoded.info.baseMint.toBase58());
}
if (!cache.get(decoded.info.baseMint)) {
toQuery.add(decoded.info.quoteMint.toBase58());
}
toQuery.add(decoded.info.bids.toBase58());
toQuery.add(decoded.info.asks.toBase58());
// TODO: only update when someone listnes to it
});
await refreshAccounts(connection, [...toQuery.keys()]);
marketEmitter.raiseMarketUpdated(new Set([...marketByMint.keys()]));
// start update loop
updateData();
bonfidaQuery();
};
initalQuery();
return () => {
window.clearTimeout(bonfidaTimer);
window.clearTimeout(timer);
};
}, [pools, marketByMint, accountsToObserve, connection]);
const midPriceInUSD = useCallback(
(mintAddress: string) => {
return getMidPrice(
marketByMint.get(mintAddress)?.marketInfo.address.toBase58(),
mintAddress
);
},
[marketByMint]
);
const subscribeToMarket = useCallback(
(mintAddress: string) => {
const info = marketByMint.get(mintAddress);
const market = cache.get(info?.marketInfo.address.toBase58() || "");
if (!market) {
return () => {};
}
// TODO: get recent volume
const bid = market.info.bids.toBase58();
const ask = market.info.asks.toBase58();
accountsToObserve.set(bid, (accountsToObserve.get(bid) || 0) + 1);
accountsToObserve.set(ask, (accountsToObserve.get(ask) || 0) + 1);
// TODO: add event queue to query for last trade
return () => {
accountsToObserve.set(bid, (accountsToObserve.get(bid) || 0) - 1);
accountsToObserve.set(ask, (accountsToObserve.get(ask) || 0) - 1);
// cleanup
[...accountsToObserve.keys()].forEach((key) => {
if ((accountsToObserve.get(key) || 0) <= 0) {
accountsToObserve.delete(key);
}
});
};
},
[marketByMint, accountsToObserve]
);
return (
<MarketsContext.Provider
value={{
midPriceInUSD,
marketEmitter,
accountsToObserve,
marketByMint,
subscribeToMarket,
dailyVolume: dailyVolume,
}}
>
{children}
</MarketsContext.Provider>
);
}
export const useMarkets = () => {
const context = useContext(MarketsContext);
return context as MarketsContextState;
};
export const useMidPriceInUSD = (mint: string) => {
const { midPriceInUSD, subscribeToMarket, marketEmitter } = useContext(
MarketsContext
) as MarketsContextState;
const [price, setPrice] = useState<number>(0);
useEffect(() => {
let subscription = subscribeToMarket(mint);
const update = () => {
if (midPriceInUSD) {
setPrice(midPriceInUSD(mint));
}
};
update();
const dispose = marketEmitter.onMarket(update);
return () => {
subscription();
dispose();
};
}, [midPriceInUSD, mint, marketEmitter, subscribeToMarket]);
return { price, isBase: price === 1.0 };
};
export const useEnrichedPools = (pools: PoolInfo[]) => {
const context = useContext(MarketsContext);
const { tokenMap } = useConnectionConfig();
const [enriched, setEnriched] = useState<any[]>([]);
const subscribeToMarket = context?.subscribeToMarket;
const marketEmitter = context?.marketEmitter;
const marketsByMint = context?.marketByMint;
const dailyVolume = context?.dailyVolume;
useEffect(() => {
if (!marketEmitter || !subscribeToMarket) {
return;
}
const mints = [...new Set([...marketsByMint?.keys()]).keys()];
const subscriptions = mints.map((m) => subscribeToMarket(m));
const update = () => {
setEnriched(
createEnrichedPools(pools, marketsByMint, dailyVolume, tokenMap)
);
};
const dispose = marketEmitter.onMarket(update);
update();
return () => {
dispose && dispose();
subscriptions.forEach((dispose) => dispose && dispose());
};
}, [
tokenMap,
pools,
dailyVolume,
subscribeToMarket,
marketEmitter,
marketsByMint,
]);
return enriched;
};
// TODO:
// 1. useEnrichedPools
// combines market and pools and user info
// 2. ADD useMidPrice with event to refresh price
// that could subscribe to multiple markets and trigger refresh of those markets only when there is active subscription
function createEnrichedPools(
pools: PoolInfo[],
marketByMint: Map<string, SerumMarket> | undefined,
poolData: Map<string, RecentPoolData> | undefined,
tokenMap: KnownTokenMap
) {
const TODAY = new Date();
if (!marketByMint) {
return [];
}
const result = pools
.filter((p) => p.pubkeys.holdingMints && p.pubkeys.holdingMints.length > 1)
.map((p, index) => {
const mints = (p.pubkeys.holdingMints || [])
.map((a) => a.toBase58())
.sort();
const indexA = mints[0] === p.pubkeys.holdingMints[0]?.toBase58() ? 0 : 1;
const indexB = indexA === 0 ? 1 : 0;
const accountA = cache.getAccount(p.pubkeys.holdingAccounts[indexA]);
const mintA = cache.getMint(mints[0]);
const accountB = cache.getAccount(p.pubkeys.holdingAccounts[indexB]);
const mintB = cache.getMint(mints[1]);
const baseMid = getMidPrice(
marketByMint.get(mints[0])?.marketInfo.address.toBase58() || "",
mints[0]
);
const baseReserveUSD = baseMid * convert(accountA, mintA);
const quote = getMidPrice(
marketByMint.get(mints[1])?.marketInfo.address.toBase58() || "",
mints[1]
);
const quoteReserveUSD = quote * convert(accountB, mintB);
const poolMint = cache.getMint(p.pubkeys.mint);
if (poolMint?.supply.eqn(0)) {
return undefined;
}
let airdropYield = calculateAirdropYield(
p,
marketByMint,
baseReserveUSD,
quoteReserveUSD
);
let volume = 0;
let volume24h =
baseMid * (poolData?.get(p.pubkeys.mint.toBase58())?.volume24hA || 0);
let fees24h = volume24h * (LIQUIDITY_PROVIDER_FEE - SERUM_FEE);
let fees = 0;
let apy = airdropYield;
let apy24h = airdropYield;
if (p.pubkeys.feeAccount) {
const feeAccount = cache.getAccount(p.pubkeys.feeAccount);
if (
poolMint &&
feeAccount &&
feeAccount.info.mint.toBase58() === p.pubkeys.mint.toBase58()
) {
const feeBalance = feeAccount?.info.amount.toNumber();
const supply = poolMint?.supply.toNumber();
const ownedPct = feeBalance / supply;
const poolOwnerFees =
ownedPct * baseReserveUSD + ownedPct * quoteReserveUSD;
volume = poolOwnerFees / 0.0004;
fees = volume * LIQUIDITY_PROVIDER_FEE;
if (fees !== 0) {
const baseVolume = (ownedPct * baseReserveUSD) / 0.0004;
const quoteVolume = (ownedPct * quoteReserveUSD) / 0.0004;
// Aproximation not true for all pools we need to fine a better way
const daysSinceInception = Math.floor(
(TODAY.getTime() - INITAL_LIQUIDITY_DATE.getTime()) /
(24 * 3600 * 1000)
);
const apy0 =
parseFloat(
((baseVolume / daysSinceInception) * LIQUIDITY_PROVIDER_FEE * 356) as any
) / baseReserveUSD;
const apy1 =
parseFloat(
((quoteVolume / daysSinceInception) * LIQUIDITY_PROVIDER_FEE * 356) as any
) / quoteReserveUSD;
apy = apy + Math.max(apy0, apy1);
const apy24h0 =
parseFloat((volume24h * LIQUIDITY_PROVIDER_FEE * 356) as any) / baseReserveUSD;
apy24h = apy24h + apy24h0;
}
}
}
const lpMint = cache.getMint(p.pubkeys.mint);
const name = getPoolName(tokenMap, p);
const link = `#/?pair=${getPoolName(tokenMap, p, false).replace(
"/",
"-"
)}`;
return {
key: p.pubkeys.account.toBase58(),
id: index,
name,
names: mints.map((m) => getTokenName(tokenMap, m)),
address: p.pubkeys.mint.toBase58(),
link,
mints,
liquidityA: convert(accountA, mintA),
liquidityAinUsd: baseReserveUSD,
liquidityB: convert(accountB, mintB),
liquidityBinUsd: quoteReserveUSD,
supply:
lpMint &&
(
lpMint?.supply.toNumber() / Math.pow(10, lpMint?.decimals || 0)
).toFixed(9),
fees,
fees24h,
liquidity: baseReserveUSD + quoteReserveUSD,
volume,
volume24h,
apy: Number.isFinite(apy) ? apy : 0,
apy24h: Number.isFinite(apy24h) ? apy24h : 0,
map: poolData,
extra: poolData?.get(p.pubkeys.account.toBase58()),
raw: p,
};
})
.filter((p) => p !== undefined);
return result;
}
function calculateAirdropYield(
p: PoolInfo,
marketByMint: Map<string, SerumMarket>,
baseReserveUSD: number,
quoteReserveUSD: number
) {
let airdropYield = 0;
let poolWithAirdrop = POOLS_WITH_AIRDROP.find((drop) =>
drop.pool.equals(p.pubkeys.mint)
);
if (poolWithAirdrop) {
airdropYield = poolWithAirdrop.airdrops.reduce((acc, item) => {
const market = marketByMint.get(item.mint.toBase58())?.marketInfo.address;
if (market) {
const midPrice = getMidPrice(market?.toBase58(), item.mint.toBase58());
acc =
acc +
// airdrop yield
((item.amount * midPrice) / (baseReserveUSD + quoteReserveUSD)) *
(365 / 30);
}
return acc;
}, 0);
}
return airdropYield;
}
const OrderBookParser = (id: PublicKey, acc: AccountInfo<Buffer>) => {
const decoded = Orderbook.LAYOUT.decode(acc.data);
const details = {
pubkey: id,
account: {
...acc,
},
info: decoded,
} as ParsedAccountBase;
return details;
};
const getMidPrice = (marketAddress?: string, mintAddress?: string) => {
const SERUM_TOKEN = TOKEN_MINTS.find(
(a) => a.address.toBase58() === mintAddress
);
if (STABLE_COINS.has(SERUM_TOKEN?.name || "")) {
return 1.0;
}
if (!marketAddress) {
return 0.0;
}
const marketInfo = cache.get(marketAddress);
if (!marketInfo) {
return 0.0;
}
const decodedMarket = marketInfo.info;
const baseMintDecimals =
cache.get(decodedMarket.baseMint)?.info.decimals || 0;
const quoteMintDecimals =
cache.get(decodedMarket.quoteMint)?.info.decimals || 0;
const market = new Market(
decodedMarket,
baseMintDecimals,
quoteMintDecimals,
undefined,
decodedMarket.programId
);
const bids = cache.get(decodedMarket.bids)?.info;
const asks = cache.get(decodedMarket.asks)?.info;
if (bids && asks) {
const bidsBook = new Orderbook(market, bids.accountFlags, bids.slab);
const asksBook = new Orderbook(market, asks.accountFlags, asks.slab);
const bestBid = bidsBook.getL2(1);
const bestAsk = asksBook.getL2(1);
if (bestBid.length > 0 && bestAsk.length > 0) {
return (bestBid[0][0] + bestAsk[0][0]) / 2.0;
}
}
return 0;
};
const refreshAccounts = async (connection: Connection, keys: string[]) => {
if (keys.length === 0) {
return [];
}
return getMultipleAccounts(connection, keys, "single").then(
({ keys, array }) => {
return array.map((item, index) => {
const address = keys[index];
return cache.add(new PublicKey(address), item);
});
}
);
};
interface SerumMarket {
marketInfo: {
address: PublicKey;
name: string;
programId: PublicKey;
deprecated: boolean;
};
// 1st query
marketAccount?: AccountInfo<Buffer>;
// 2nd query
mintBase?: AccountInfo<Buffer>;
mintQuote?: AccountInfo<Buffer>;
bidAccount?: AccountInfo<Buffer>;
askAccount?: AccountInfo<Buffer>;
eventQueue?: AccountInfo<Buffer>;
swap?: {
dailyVolume: number;
};
midPrice?: (mint?: PublicKey) => number;
}