diff --git a/.env.development b/.env.development index 9178fe5c0..c8feca14a 100644 --- a/.env.development +++ b/.env.development @@ -23,7 +23,7 @@ NEXT_PUBLIC_AVATAR_COLLECTION_ID="2e55d4bf2e85715b63-ZEITASTAGE" NEXT_PUBLIC_SINGULAR_URL="https://singular-rmrk2-dev.vercel.app" NEXT_PUBLIC_RMRK_INDEXER_API="https://gql2.rmrk.dev/v1/graphql" NEXT_PUBLIC_IPFS_NODE="http://ipfs.zeitgeist.pm:5001" -NEXT_PUBLIC_RMRK_CHAIN_RPC_NODE="wss://staging.node.rmrk.app" +NEXT_PUBLIC_RMRK_CHAIN_RPC_NODE="wss://kusama-node-staging.rmrk.link/" NEXT_PUBLIC_AVATAR_API_HOST="https://avatar-bsr.zeitgeist.pm/" #enable in dev/staging to inspect react-query cache and query handling. diff --git a/components/assets/AssetActionButtons/AssetTradingButtons.tsx b/components/assets/AssetActionButtons/AssetTradingButtons.tsx index a06d95f67..c95c742fc 100644 --- a/components/assets/AssetActionButtons/AssetTradingButtons.tsx +++ b/components/assets/AssetActionButtons/AssetTradingButtons.tsx @@ -1,10 +1,18 @@ import { Dialog } from "@headlessui/react"; -import { ScalarAssetId, CategoricalAssetId } from "@zeitgeistpm/sdk"; +import { + CategoricalAssetId, + ScalarAssetId, + getMarketIdOf, +} from "@zeitgeistpm/sdk"; import TradeForm from "components/trade-form"; +import Amm2TradeForm from "components/trade-form/Amm2TradeForm"; +import { TradeTabType } from "components/trade-form/TradeTab"; import Modal from "components/ui/Modal"; import SecondaryButton from "components/ui/SecondaryButton"; +import { useMarket } from "lib/hooks/queries/useMarket"; import { useTradeItem } from "lib/hooks/trade"; import { useState } from "react"; +import { ScoringRule } from "@zeitgeistpm/indexer"; const AssetTradingButtons = ({ assetId, @@ -13,6 +21,8 @@ const AssetTradingButtons = ({ }) => { const [isOpen, setIsOpen] = useState(false); const { data: tradeItem, set: setTradeItem } = useTradeItem(); + const marketId = getMarketIdOf(assetId); + const { data: market } = useMarket({ marketId }); return ( <> @@ -43,7 +53,19 @@ const AssetTradingButtons = ({ {tradeItem && ( setIsOpen(false)}> - + {market?.scoringRule === ScoringRule.Lmsr ? ( + + ) : ( + + )} )} diff --git a/components/liquidity/MarketLiquiditySection.tsx b/components/liquidity/MarketLiquiditySection.tsx index e704a18da..6fd422950 100644 --- a/components/liquidity/MarketLiquiditySection.tsx +++ b/components/liquidity/MarketLiquiditySection.tsx @@ -19,6 +19,7 @@ import { formatScalarOutcome } from "lib/util/format-scalar-outcome"; import { perbillToNumber } from "lib/util/perbill-to-number"; import { FC, PropsWithChildren, useState } from "react"; import { AiOutlineInfoCircle } from "react-icons/ai"; +import { ScoringRule } from "@zeitgeistpm/indexer"; export const MarketLiquiditySection = ({ market, @@ -27,9 +28,13 @@ export const MarketLiquiditySection = ({ market: FullMarketFragment; poll?: boolean; }) => { + const marketHasPool = + (market?.scoringRule === ScoringRule.Cpmm && market.pool != null) || + (market?.scoringRule === ScoringRule.Lmsr && market.neoPool != null); + return ( <> - {poll && !market?.pool?.poolId && ( + {poll && !marketHasPool && ( <>
@@ -39,13 +44,13 @@ export const MarketLiquiditySection = ({
)} - {market?.pool?.poolId && ( + {marketHasPool && ( <>
@@ -152,12 +157,6 @@ const LiquidityHeader = ({ market }: { market: FullMarketFragment }) => {
- - {predictionDisplay} -
{!wallet.connected ? ( diff --git a/components/liquidity/PoolTable.tsx b/components/liquidity/PoolTable.tsx index 0fc2961f7..b85d75ac2 100644 --- a/components/liquidity/PoolTable.tsx +++ b/components/liquidity/PoolTable.tsx @@ -1,6 +1,7 @@ import { IOBaseAssetId, parseAssetId, ZTG } from "@zeitgeistpm/sdk"; import Table, { TableColumn, TableData } from "components/ui/Table"; import Decimal from "decimal.js"; +import { useAmm2Pool } from "lib/hooks/queries/amm2/useAmm2Pool"; import { useAccountPoolAssetBalances } from "lib/hooks/queries/useAccountPoolAssetBalances"; import { useAssetMetadata } from "lib/hooks/queries/useAssetMetadata"; import { useAssetUsdPrice } from "lib/hooks/queries/useAssetUsdPrice"; @@ -10,6 +11,7 @@ import { usePool } from "lib/hooks/queries/usePool"; import { usePoolBaseBalance } from "lib/hooks/queries/usePoolBaseBalance"; import { calcMarketColors } from "lib/util/color-calc"; import { parseAssetIdString } from "lib/util/parse-asset-id"; +import { ScoringRule } from "@zeitgeistpm/indexer"; const poolTableColums: TableColumn[] = [ { @@ -17,11 +19,6 @@ const poolTableColums: TableColumn[] = [ accessor: "token", type: "token", }, - { - header: "Weights", - accessor: "weights", - type: "percentage", - }, { header: "Pool Balance", accessor: "poolBalance", @@ -33,10 +30,10 @@ const PoolTable = ({ poolId, marketId, }: { - poolId: number; + poolId?: number; marketId: number; }) => { - const { data: pool } = usePool({ poolId }); + const { data: pool } = usePool(poolId != null ? { poolId } : undefined); const { data: market } = useMarket({ marketId }); const baseAssetId = pool?.baseAsset ? parseAssetId(pool.baseAsset).unrightOr(undefined) @@ -50,20 +47,25 @@ const PoolTable = ({ const { data: basePoolBalance } = usePoolBaseBalance(poolId); const { data: baseAssetUsdPrice } = useAssetUsdPrice(baseAssetId); const { data: spotPrices } = useMarketSpotPrices(marketId); + const { data: amm2Pool } = useAmm2Pool(marketId); const colors = market?.categories ? calcMarketColors(marketId, market.categories.length) : []; + const assetIds = + market?.scoringRule === ScoringRule.Cpmm + ? pool?.weights?.map((weight) => parseAssetIdString(weight?.assetId)) + : amm2Pool?.assetIds; + const tableData: TableData[] = - pool?.weights?.map((asset, index) => { + assetIds?.map((assetId, index) => { let amount: Decimal | undefined; let usdValue: Decimal | undefined; let category: | { color?: string | null; name?: string | null } | undefined | null; - const assetId = parseAssetIdString(asset?.assetId); if (IOBaseAssetId.is(assetId)) { amount = basePoolBalance ?? undefined; @@ -83,10 +85,6 @@ const PoolTable = ({ color: colors[index] || "#ffffff", label: category?.name ?? "", }, - weights: new Decimal(asset!.weight) - .div(pool.totalWeight) - .mul(100) - .toNumber(), poolBalance: { value: amount?.div(ZTG).toDecimalPlaces(2).toNumber() ?? 0, usdValue: usdValue?.div(ZTG).toDecimalPlaces(2).toNumber(), diff --git a/components/markets/MarketContextActionOutcomeSelector.tsx b/components/markets/MarketContextActionOutcomeSelector.tsx index d1a62e42a..70d34925a 100644 --- a/components/markets/MarketContextActionOutcomeSelector.tsx +++ b/components/markets/MarketContextActionOutcomeSelector.tsx @@ -19,7 +19,7 @@ export type MarketContextActionOutcomeSelectorProps = { const SEARCH_ITEMS_THRESHOLD = 5; -export const MarketContextActionOutcomeSelector = ({ +const MarketContextActionOutcomeSelector = ({ market, selected, options, @@ -88,14 +88,14 @@ export const MarketContextActionOutcomeSelector = ({ }} > setOpen(!open)}> -
+
{(text) => <>{text}} - +
{ interface TradeResultProps { type: "buy" | "sell"; - amount: Decimal; + amount?: Decimal; tokenName?: string; - baseTokenAmount: Decimal; + baseTokenAmount?: Decimal; baseToken?: string; marketId: number; marketQuestion?: string; @@ -49,12 +49,12 @@ const TradeResult = ({ onContinueClick, }: TradeResultProps) => { const marketUrl = `https://app.zeitgeist.pm/markets/${marketId}`; - const potentialGain = amount.div(baseTokenAmount); + const potentialGain = amount?.div(baseTokenAmount ?? 0); const twitterBaseUrl = "https://twitter.com/intent/tweet?text="; const tweetUrl = type === "buy" ? `${twitterBaseUrl}I'm using %40ZeitgeistPM to bet on "${marketQuestion}" %0A%0AIf I'm right, I'll gain ${potentialGain - .minus(1) + ?.minus(1) .times(100) .toFixed( 0, @@ -64,12 +64,12 @@ const TradeResult = ({ return (
You've just {type === "buy" ? "bought" : "sold"}
-
{amount.toFixed(2)}
+
{amount?.toFixed(2)}
{tokenName} Predictions For
- {baseTokenAmount.toFixed(2)} {baseToken} + {baseTokenAmount?.toFixed(2)} {baseToken}
{ + const [tabType, setTabType] = useState(); + const [showSuccessBox, setShowSuccessBox] = useState(false); + const [amountReceived, setAmountReceived] = useState(); + const [amountIn, setAmountIn] = useState(); + const [outcomeAsset, setOutcomeAsset] = useState(); + const { data: market } = useMarket({ marketId }); + const baseAsset = parseAssetIdString(market?.baseAsset); + const { data: assetMetadata } = useAssetMetadata(baseAsset); + const baseSymbol = assetMetadata?.symbol; + + useEffect(() => { + setTabType(selectedTab ?? TradeTabType.Buy); + }, [selectedTab]); + + const handleSuccess = (data: ISubmittableResult) => { + const { events } = data; + for (const eventData of events) { + const { event } = eventData; + const { data } = event; + if ( + event.section.toString() === "neoSwaps" && + (event.method.toString() === "SellExecuted" || + event.method.toString() === "BuyExecuted") + ) { + const amountOut: number = data["amountOut"].toNumber(); + setAmountReceived(new Decimal(amountOut ?? 0)); + setShowSuccessBox(true); + } + } + }; + + return ( + <> + {showSuccessBox === true ? ( + { + setShowSuccessBox(false); + }} + /> + ) : ( + { + setTabType(index); + }} + selectedIndex={tabType} + > + + + Buy + + + Sell + + + + + { + handleSuccess(data); + setOutcomeAsset(asset); + setAmountIn(amount); + }} + /> + + + { + handleSuccess(data); + setOutcomeAsset(asset); + setAmountIn(amount); + }} + /> + + + + )} + + ); +}; + +export default Amm2TradeForm; diff --git a/components/trade-form/BuyForm.tsx b/components/trade-form/BuyForm.tsx new file mode 100644 index 000000000..cb802f071 --- /dev/null +++ b/components/trade-form/BuyForm.tsx @@ -0,0 +1,303 @@ +import { + isRpcSdk, + MarketOutcomeAssetId, + parseAssetId, + ZTG, +} from "@zeitgeistpm/sdk"; +import MarketContextActionOutcomeSelector from "components/markets/MarketContextActionOutcomeSelector"; +import FormTransactionButton from "components/ui/FormTransactionButton"; +import Input from "components/ui/Input"; +import Decimal from "decimal.js"; +import { DEFAULT_SLIPPAGE_PERCENTAGE } from "lib/constants"; +import { + lookupAssetReserve, + useAmm2Pool, +} from "lib/hooks/queries/amm2/useAmm2Pool"; +import { useAssetMetadata } from "lib/hooks/queries/useAssetMetadata"; +import { useBalance } from "lib/hooks/queries/useBalance"; +import { useChainConstants } from "lib/hooks/queries/useChainConstants"; +import { useMarket } from "lib/hooks/queries/useMarket"; +import { useExtrinsic } from "lib/hooks/useExtrinsic"; +import { useSdkv2 } from "lib/hooks/useSdkv2"; +import { useNotifications } from "lib/state/notifications"; +import { useWallet } from "lib/state/wallet"; +import { + approximateMaxAmountInForBuy, + calculateSpotPrice, + calculateSwapAmountOutForBuy, +} from "lib/util/amm2"; +import { formatNumberCompact } from "lib/util/format-compact"; +import { parseAssetIdString } from "lib/util/parse-asset-id"; +import { useState, useEffect, useMemo } from "react"; +import { useForm } from "react-hook-form"; +import { ISubmittableResult } from "@polkadot/types/types"; + +const slippageMultiplier = (100 - DEFAULT_SLIPPAGE_PERCENTAGE) / 100; + +const BuyForm = ({ + marketId, + initialAsset, + onSuccess, +}: { + marketId: number; + initialAsset?: MarketOutcomeAssetId; + onSuccess: ( + data: ISubmittableResult, + outcomeAsset: MarketOutcomeAssetId, + amountIn: Decimal, + ) => void; +}) => { + const { data: constants } = useChainConstants(); + const { + register, + handleSubmit, + getValues, + formState, + watch, + setValue, + trigger, + } = useForm({ + reValidateMode: "onChange", + mode: "onChange", + }); + const [sdk] = useSdkv2(); + const notificationStore = useNotifications(); + const { data: market } = useMarket({ + marketId, + }); + const wallet = useWallet(); + const baseAsset = parseAssetIdString(market?.baseAsset); + const { data: assetMetadata } = useAssetMetadata(baseAsset); + const baseSymbol = assetMetadata?.symbol; + const { data: baseAssetBalance } = useBalance(wallet.realAddress, baseAsset); + const { data: pool } = useAmm2Pool(marketId); + + const outcomeAssets = market?.outcomeAssets.map( + (assetIdString) => + parseAssetId(assetIdString).unwrap() as MarketOutcomeAssetId, + ); + const [selectedAsset, setSelectedAsset] = useState< + MarketOutcomeAssetId | undefined + >(initialAsset ?? outcomeAssets?.[0]); + + const formAmount = getValues("amount"); + + const amountIn = new Decimal( + formAmount && formAmount !== "" ? formAmount : 0, + ).mul(ZTG); + const assetReserve = + pool?.reserves && lookupAssetReserve(pool?.reserves, selectedAsset); + + const maxAmountIn = useMemo(() => { + return ( + assetReserve && + pool && + approximateMaxAmountInForBuy(assetReserve, pool.liquidity) + ); + }, [assetReserve, pool?.liquidity]); + + const { + amountOut, + spotPrice, + newSpotPrice, + priceImpact, + maxProfit, + minAmountOut, + } = useMemo(() => { + const amountOut = + assetReserve && pool.liquidity + ? calculateSwapAmountOutForBuy( + assetReserve, + amountIn, + pool.liquidity, + new Decimal(0.01), + new Decimal(0.001), + ) + : new Decimal(0); + + const spotPrice = + assetReserve && calculateSpotPrice(assetReserve, pool?.liquidity); + + const poolAmountOut = amountOut.minus(amountIn); + const newSpotPrice = + pool?.liquidity && + assetReserve && + calculateSpotPrice(assetReserve?.minus(poolAmountOut), pool?.liquidity); + + const priceImpact = spotPrice + ? newSpotPrice?.div(spotPrice).minus(1).mul(100) + : new Decimal(0); + + const maxProfit = amountOut.minus(amountIn); + + const minAmountOut = amountOut.mul(slippageMultiplier); + + return { + amountOut, + spotPrice, + newSpotPrice, + priceImpact, + maxProfit, + minAmountOut, + }; + }, [amountIn, pool?.liquidity, assetReserve]); + + const { isLoading, send, fee } = useExtrinsic( + () => { + const amount = getValues("amount"); + if ( + !isRpcSdk(sdk) || + !amount || + amount === "" || + market?.categories?.length == null || + !selectedAsset + ) { + return; + } + + return sdk.api.tx.neoSwaps.buy( + marketId, + market?.categories?.length, + selectedAsset, + new Decimal(amount).mul(ZTG).toFixed(0), + minAmountOut.toFixed(0), + ); + }, + { + onSuccess: (data) => { + notificationStore.pushNotification(`Successfully traded`, { + type: "Success", + }); + onSuccess(data, selectedAsset!, amountIn); + }, + }, + ); + + useEffect(() => { + const subscription = watch((value, { name, type }) => { + const changedByUser = type != null; + + if (!changedByUser || !baseAssetBalance || !maxAmountIn) return; + + if (name === "percentage") { + const max = baseAssetBalance.greaterThan(maxAmountIn) + ? maxAmountIn + : baseAssetBalance; + setValue( + "amount", + max.mul(value.percentage).div(100).div(ZTG).toNumber(), + ); + } else if (name === "amount" && value.amount !== "") { + setValue( + "percentage", + new Decimal(value.amount) + .mul(ZTG) + .div(baseAssetBalance) + .mul(100) + .toString(), + ); + } + trigger("amount"); + }); + return () => subscription.unsubscribe(); + }, [watch, baseAssetBalance, maxAmountIn]); + + const onSubmit = () => { + send(); + }; + return ( +
+
+
+
{amountOut.div(ZTG).toFixed(3)}
+
+ {market && selectedAsset && ( + { + setSelectedAsset(assetId); + trigger(); + }} + /> + )} +
+
+
For
+
+ { + if (value > (baseAssetBalance?.div(ZTG).toNumber() ?? 0)) { + return `Insufficient balance. Current balance: ${baseAssetBalance + ?.div(ZTG) + .toFixed(3)}`; + } else if (value <= 0) { + return "Value cannot be zero or less"; + } else if (maxAmountIn?.div(ZTG)?.lessThanOrEqualTo(value)) { + return `Maximum amount of ${baseSymbol} that can be traded is ${maxAmountIn + .div(ZTG) + .toFixed(3)}`; + } + }, + })} + /> +
+ {constants?.tokenSymbol} +
+
+ +
+
+ <>{formState.errors["amount"]?.message} +
+
+
Max profit:
+
+ {maxProfit.div(ZTG).toFixed(2)} {baseSymbol} +
+
+
+
Price after trade:
+
+ {newSpotPrice?.toFixed(2)} ({priceImpact?.toFixed(2)}%) +
+
+
+ +
+
Buy
+
+ Network fee:{" "} + {formatNumberCompact(fee?.amount.div(ZTG).toNumber() ?? 0)}{" "} + {fee?.symbol} +
+
+
+
+
+ ); +}; + +export default BuyForm; diff --git a/components/trade-form/SellForm.tsx b/components/trade-form/SellForm.tsx new file mode 100644 index 000000000..b156667a9 --- /dev/null +++ b/components/trade-form/SellForm.tsx @@ -0,0 +1,285 @@ +import { + isRpcSdk, + MarketOutcomeAssetId, + parseAssetId, + ZTG, +} from "@zeitgeistpm/sdk"; +import MarketContextActionOutcomeSelector from "components/markets/MarketContextActionOutcomeSelector"; +import FormTransactionButton from "components/ui/FormTransactionButton"; +import Input from "components/ui/Input"; +import Decimal from "decimal.js"; +import { DEFAULT_SLIPPAGE_PERCENTAGE } from "lib/constants"; +import { + lookupAssetReserve, + useAmm2Pool, +} from "lib/hooks/queries/amm2/useAmm2Pool"; +import { useBalance } from "lib/hooks/queries/useBalance"; +import { useChainConstants } from "lib/hooks/queries/useChainConstants"; +import { useMarket } from "lib/hooks/queries/useMarket"; +import { useExtrinsic } from "lib/hooks/useExtrinsic"; +import { useSdkv2 } from "lib/hooks/useSdkv2"; +import { useNotifications } from "lib/state/notifications"; +import { useWallet } from "lib/state/wallet"; +import { + approximateMaxAmountInForSell, + calculateSpotPrice, + calculateSwapAmountOutForSell, +} from "lib/util/amm2"; +import { formatNumberCompact } from "lib/util/format-compact"; +import { parseAssetIdString } from "lib/util/parse-asset-id"; +import { useState, useEffect, useMemo } from "react"; +import { useForm } from "react-hook-form"; +import { ISubmittableResult } from "@polkadot/types/types"; + +const slippageMultiplier = (100 - DEFAULT_SLIPPAGE_PERCENTAGE) / 100; + +const SellForm = ({ + marketId, + initialAsset, + onSuccess, +}: { + marketId: number; + initialAsset?: MarketOutcomeAssetId; + onSuccess: ( + data: ISubmittableResult, + outcomeAsset: MarketOutcomeAssetId, + amountIn: Decimal, + ) => void; +}) => { + const { data: constants } = useChainConstants(); + const { + register, + handleSubmit, + getValues, + formState, + watch, + setValue, + trigger, + } = useForm({ + reValidateMode: "onChange", + mode: "onChange", + }); + const [sdk] = useSdkv2(); + const notificationStore = useNotifications(); + const { data: market } = useMarket({ + marketId, + }); + const wallet = useWallet(); + const { data: pool } = useAmm2Pool(marketId); + + const outcomeAssets = market?.outcomeAssets.map( + (assetIdString) => + parseAssetId(assetIdString).unwrap() as MarketOutcomeAssetId, + ); + const [selectedAsset, setSelectedAsset] = useState< + MarketOutcomeAssetId | undefined + >(initialAsset ?? outcomeAssets?.[0]); + + const { data: selectedAssetBalance } = useBalance( + wallet.realAddress, + selectedAsset, + ); + const formAmount = getValues("amount"); + + const amountIn = new Decimal( + formAmount && formAmount !== "" ? formAmount : 0, + ).mul(ZTG); + const assetReserve = + pool?.reserves && lookupAssetReserve(pool?.reserves, selectedAsset); + + const maxAmountIn = useMemo(() => { + return ( + assetReserve && + pool && + approximateMaxAmountInForSell(assetReserve, pool.liquidity) + ); + }, [assetReserve, pool?.liquidity]); + + const { amountOut, newSpotPrice, priceImpact, minAmountOut } = useMemo(() => { + const amountOut = + assetReserve && pool.liquidity + ? calculateSwapAmountOutForSell( + assetReserve, + amountIn, + pool.liquidity, + new Decimal(0), + new Decimal(0), + ) + : new Decimal(0); + + const spotPrice = + assetReserve && calculateSpotPrice(assetReserve, pool?.liquidity); + + const poolAmountIn = amountIn.minus(amountOut); + const newSpotPrice = + pool?.liquidity && + assetReserve && + calculateSpotPrice(assetReserve?.plus(poolAmountIn), pool?.liquidity); + + const priceImpact = spotPrice + ? newSpotPrice?.div(spotPrice).minus(1).mul(100) + : new Decimal(0); + + const minAmountOut = amountOut.mul(slippageMultiplier); + + return { + amountOut, + spotPrice, + newSpotPrice, + priceImpact, + minAmountOut, + }; + }, [amountIn, pool?.liquidity, assetReserve]); + + const { isLoading, send, fee } = useExtrinsic( + () => { + const amount = getValues("amount"); + if ( + !isRpcSdk(sdk) || + !amount || + amount === "" || + market?.categories?.length == null || + !selectedAsset + ) { + return; + } + + return sdk.api.tx.neoSwaps.sell( + marketId, + market?.categories?.length, + selectedAsset, + new Decimal(amount).mul(ZTG).toFixed(0), + minAmountOut.toFixed(0), + ); + }, + { + onSuccess: (data) => { + notificationStore.pushNotification(`Successfully traded`, { + type: "Success", + }); + onSuccess(data, selectedAsset!, amountIn); + }, + }, + ); + + useEffect(() => { + const subscription = watch((value, { name, type }) => { + const changedByUser = type != null; + + if (!changedByUser || !selectedAssetBalance || !maxAmountIn) return; + + if (name === "percentage") { + const max = selectedAssetBalance.greaterThan(maxAmountIn) + ? maxAmountIn + : selectedAssetBalance; + setValue( + "amount", + max.mul(value.percentage).div(100).div(ZTG).toNumber(), + ); + } else if (name === "amount" && value.amount !== "") { + setValue( + "percentage", + new Decimal(value.amount) + .mul(ZTG) + .div(selectedAssetBalance) + .mul(100) + .toString(), + ); + } + trigger("amount"); + }); + return () => subscription.unsubscribe(); + }, [watch, selectedAssetBalance, maxAmountIn]); + + const onSubmit = () => { + send(); + }; + return ( +
+
+
+ { + if (value > (selectedAssetBalance?.div(ZTG).toNumber() ?? 0)) { + return `Insufficient balance. Current balance: ${selectedAssetBalance + ?.div(ZTG) + .toFixed(3)}`; + } else if (value <= 0) { + return "Value cannot be zero or less"; + } else if (maxAmountIn?.div(ZTG)?.lessThanOrEqualTo(value)) { + return `Maximum amount that can be traded is ${maxAmountIn + .div(ZTG) + .toFixed(3)}`; + } + }, + })} + /> +
+ {market && selectedAsset && ( + { + setSelectedAsset(assetId); + }} + /> + )} +
+
+
For
+
+
{amountOut.div(ZTG).toFixed(5)}
+
{constants?.tokenSymbol}
+
+ +
+
+ <>{formState.errors["amount"]?.message} +
+
+
Price after trade:
+
+ {newSpotPrice?.toFixed(2)} ({priceImpact?.toFixed(2)}%) +
+
+
+ +
+
Sell
+
+ Network fee:{" "} + {formatNumberCompact(fee?.amount.div(ZTG).toNumber() ?? 0)}{" "} + {fee?.symbol} +
+
+
+
+
+ ); +}; + +export default SellForm; diff --git a/components/ui/inputs.tsx b/components/ui/inputs.tsx index e78e0c118..0d25913f0 100644 --- a/components/ui/inputs.tsx +++ b/components/ui/inputs.tsx @@ -24,7 +24,7 @@ const inputClasses = "bg-gray-100 dark:bg-black text-ztg-14-150 w-full rounded-lg h-ztg-40 p-ztg-8 focus:outline-none dark:border-black text-black dark:text-white"; const invalidClasses = "!border-vermilion !text-vermilion"; -export const Input: FC> = +const Input: FC> = React.forwardRef< HTMLInputElement, InputProps & InputHTMLAttributes diff --git a/lib/hooks/queries/amm2/useAmm2Pool.ts b/lib/hooks/queries/amm2/useAmm2Pool.ts new file mode 100644 index 000000000..746459cda --- /dev/null +++ b/lib/hooks/queries/amm2/useAmm2Pool.ts @@ -0,0 +1,86 @@ +import { useQuery } from "@tanstack/react-query"; +import { + AssetId, + IOCategoricalAssetId, + IOMarketOutcomeAssetId, + MarketOutcomeAssetId, + isRpcSdk, +} from "@zeitgeistpm/sdk"; +import Decimal from "decimal.js"; +import { useSdkv2 } from "lib/hooks/useSdkv2"; +import { parseAssetIdString } from "lib/util/parse-asset-id"; + +export const amm2PoolKey = "amm2-pool"; + +type ReserveMap = Map; + +export type Amm2Pool = { + accountId: string; + baseAsset: AssetId; + liquidity: Decimal; + swapFee: Decimal; + reserves: ReserveMap; + assetIds: MarketOutcomeAssetId[]; +}; + +export const useAmm2Pool = (marketId?: number) => { + const [sdk, id] = useSdkv2(); + + const enabled = !!sdk && marketId != null && isRpcSdk(sdk); + const query = useQuery( + [id, amm2PoolKey, marketId], + async () => { + if (!enabled) return; + const res = await sdk.api.query.neoSwaps.pools(marketId); + const unwrappedRes = res.unwrapOr(null); + + if (unwrappedRes) { + const reserves: ReserveMap = new Map(); + const assetIds: MarketOutcomeAssetId[] = []; + + unwrappedRes.reserves.forEach((reserve, asset) => { + const assetId = parseAssetIdString(asset.toString()); + if (IOMarketOutcomeAssetId.is(assetId)) { + reserves.set( + IOCategoricalAssetId.is(assetId) + ? assetId.CategoricalOutcome[1] + : assetId.ScalarOutcome[1], + new Decimal(reserve.toString()), + ); + assetIds.push(assetId); + } + }); + + const pool: Amm2Pool = { + accountId: unwrappedRes.accountId.toString(), + baseAsset: parseAssetIdString(unwrappedRes.collateral.toString())!, + liquidity: new Decimal(unwrappedRes.liquidityParameter.toString()), + swapFee: new Decimal(unwrappedRes.swapFee.toString()), + reserves, + assetIds, + }; + + return pool; + } + }, + { + enabled: enabled, + }, + ); + + return query; +}; + +export const lookupAssetReserve = ( + map: ReserveMap, + asset?: string | AssetId, +) => { + const assetId = parseAssetIdString(asset); + if (IOMarketOutcomeAssetId.is(assetId)) { + return map.get( + IOCategoricalAssetId.is(assetId) + ? assetId.CategoricalOutcome[1] + : assetId.ScalarOutcome[1], + ); + } +}; diff --git a/lib/hooks/queries/useMarketSpotPrices.ts b/lib/hooks/queries/useMarketSpotPrices.ts index 7e3fb617f..e24b942d1 100644 --- a/lib/hooks/queries/useMarketSpotPrices.ts +++ b/lib/hooks/queries/useMarketSpotPrices.ts @@ -1,13 +1,15 @@ +import { OrmlTokensAccountData } from "@polkadot/types/lookup"; import { useQuery } from "@tanstack/react-query"; +import { FullMarketFragment, ScoringRule } from "@zeitgeistpm/indexer"; import { isRpcSdk } from "@zeitgeistpm/sdk"; import Decimal from "decimal.js"; import { calcSpotPrice } from "lib/math"; +import { calculateSpotPrice } from "lib/util/amm2"; +import { calcResolvedMarketPrices } from "lib/util/calc-resolved-market-prices"; import { useSdkv2 } from "../useSdkv2"; +import { Amm2Pool, useAmm2Pool } from "./amm2/useAmm2Pool"; import { useAccountPoolAssetBalances } from "./useAccountPoolAssetBalances"; import { useMarket } from "./useMarket"; -import { FullMarketFragment } from "@zeitgeistpm/indexer"; -import { OrmlTokensAccountData } from "@polkadot/types/lookup"; -import { calcResolvedMarketPrices } from "lib/util/calc-resolved-market-prices"; import { usePoolBaseBalance } from "./usePoolBaseBalance"; export const marketSpotPricesKey = "market-spot-prices"; @@ -34,22 +36,31 @@ export const useMarketSpotPrices = ( blockNumber, ); + const { data: amm2Pool } = useAmm2Pool(marketId); + const enabled = isRpcSdk(sdk) && marketId != null && - !!pool && !!market && - !!basePoolBalance && - !!balances && - balances.length !== 0; + !!(amm2Pool || (pool && basePoolBalance && balances)); const query = useQuery( - [id, marketSpotPricesKey, pool, blockNumber, balances, basePoolBalance], + [ + id, + marketSpotPricesKey, + pool, + blockNumber, + balances, + basePoolBalance, + amm2Pool, + ], async () => { if (!enabled) return; const spotPrices: MarketPrices = market?.status !== "Resolved" - ? calcMarketPrices(market, basePoolBalance, balances) + ? market.scoringRule === ScoringRule.Lmsr + ? calcMarketPricesAmm2(amm2Pool!) + : calcMarketPrices(market, basePoolBalance!, balances!) : calcResolvedMarketPrices(market); return spotPrices; @@ -62,6 +73,20 @@ export const useMarketSpotPrices = ( return query; }; +const calcMarketPricesAmm2 = (pool: Amm2Pool) => { + const spotPrices: MarketPrices = new Map(); + + Array.from(pool.reserves.values()).forEach((reserve, index) => { + const spotPrice = calculateSpotPrice(reserve, pool.liquidity); + + if (!spotPrice.isNaN()) { + spotPrices.set(index, spotPrice); + } + }); + + return spotPrices; +}; + const calcMarketPrices = ( market: FullMarketFragment, basePoolBalance: Decimal, diff --git a/lib/hooks/useSubscribeBlockEvents.ts b/lib/hooks/useSubscribeBlockEvents.ts index b8252afae..7cefca0fe 100644 --- a/lib/hooks/useSubscribeBlockEvents.ts +++ b/lib/hooks/useSubscribeBlockEvents.ts @@ -7,6 +7,7 @@ import { balanceRootKey } from "./queries/useBalance"; import { currencyBalanceRootKey } from "./queries/useCurrencyBalances"; import { tradeItemStateRootQueryKey } from "./queries/useTradeItemState"; import { useSdkv2 } from "./useSdkv2"; +import { amm2PoolKey } from "./queries/amm2/useAmm2Pool"; export const useSubscribeBlockEvents = () => { const [sdk, id] = useSdkv2(); @@ -16,6 +17,7 @@ export const useSubscribeBlockEvents = () => { if (sdk && isRpcSdk(sdk)) { sdk.api.query.system.events((events) => { const accounts = new Set(); + const amm2MarketIds = new Set(); events.forEach((record) => { const { event } = record; @@ -27,6 +29,10 @@ export const useSubscribeBlockEvents = () => { types[index].type === "AccountId32" ) { accounts.add(data.toString()); + } else if (event.section === "neoSwaps") { + if (event.data.names?.includes("marketId")) { + amm2MarketIds.add(event.data["marketId"].toString()); + } } }); }); @@ -50,6 +56,10 @@ export const useSubscribeBlockEvents = () => { ]); queryClient.invalidateQueries([id, currencyBalanceRootKey, account]); }); + + amm2MarketIds.forEach((marketId) => { + queryClient.invalidateQueries([id, amm2PoolKey, Number(marketId)]); + }); }); } }, [sdk]); diff --git a/lib/util/amm2.spec.ts b/lib/util/amm2.spec.ts index e17eac921..6f22322ff 100644 --- a/lib/util/amm2.spec.ts +++ b/lib/util/amm2.spec.ts @@ -1,6 +1,7 @@ import Decimal from "decimal.js"; import { calculateSpotPrice, + approximateMaxAmountInForBuy, calculateSwapAmountOutForBuy, calculateSwapAmountOutForSell, } from "./amm2"; @@ -17,7 +18,7 @@ describe("amm2", () => { new Decimal(0), ); - expect(amountOut.toFixed(0)).toEqual("58496250072"); + expect(amountOut.toFixed(0)).toEqual("158496250072"); }); }); @@ -63,4 +64,42 @@ describe("amm2", () => { expect(amountOut.toFixed(5)).toEqual("0.25000"); }); }); + + describe("approximateMaxAmountInForBuy", () => { + //seems like correct number would be 41 + test("should work", () => { + const amountOut = approximateMaxAmountInForBuy( + new Decimal(59_9567744280), + new Decimal(144_2695040889), + ); + + expect(amountOut.toFixed(0)).toEqual("4867389110738"); + }); + }); + + test("all functions", () => { + const liquidity = new Decimal(144.00003701590745); + const reserves = [ + new Decimal(59.00001516623987), + new Decimal(156.98193508578956), + ]; + const spotPrices = [0.6638346230341853, 0.33616537696581467]; + const amountIn = new Decimal(486); + + const amountOut = calculateSwapAmountOutForBuy( + reserves[0], + amountIn, + liquidity, + new Decimal(0), + new Decimal(0), + ); + const poolAmountOut = amountOut.minus(amountIn); + const newReserve = reserves[0].minus(poolAmountOut); + const newSpotPrice = calculateSpotPrice(newReserve, liquidity); + + expect(amountOut.toFixed(5)).toEqual("543.33399"); + expect(poolAmountOut.toFixed(5)).toEqual("57.33399"); + expect(newReserve.toFixed(5)).toEqual("1.66603"); + expect(newSpotPrice.toFixed(5)).toEqual("0.98850"); + }); }); diff --git a/lib/util/amm2.ts b/lib/util/amm2.ts index a3df4acbb..f0acf5a12 100644 --- a/lib/util/amm2.ts +++ b/lib/util/amm2.ts @@ -4,7 +4,7 @@ import Decimal from "decimal.js"; export const calculateSwapAmountOutForBuy = ( reserve: Decimal, // amount of asset you want to buy in the pool amountIn: Decimal, // amount you want to spend - liquidity: Decimal, + liquidity: Decimal, // liqudity parameter of the pool poolFee: Decimal, // 1% is 0.01 creatorFee: Decimal, // 1% is 0.01 ) => { @@ -22,14 +22,15 @@ export const calculateSwapAmountOutForBuy = ( .ln() .mul(liquidity) .plus(reserve) - .minus(amountIn); + .minus(amountIn) + .plus(amountIn); }; // sell outcome token for the base asset export const calculateSwapAmountOutForSell = ( reserve: Decimal, // amount of asset you want to sell in the pool - amountIn: Decimal, //amount of asset to sell - liquidity: Decimal, + amountIn: Decimal, // amount of asset to sell + liquidity: Decimal, // liqudity parameter of the pool poolFee: Decimal, // 1% is 0.01 creatorFee: Decimal, // 1% is 0.01 ) => { @@ -53,7 +54,51 @@ export const calculateSwapAmountOutForSell = ( export const calculateSpotPrice = ( reserve: Decimal, // amount of asset in the pool - liquidity: Decimal, + liquidity: Decimal, // liqudity parameter of the pool ) => { return new Decimal(0).minus(reserve).div(liquidity).exp(); }; + +export const approximateMaxAmountInForBuy = ( + reserve: Decimal, // amount of asset in the pool + liquidity: Decimal, // liqudity parameter of the pool +) => { + const price = calculateSpotPrice(reserve, liquidity).toNumber(); + + return liquidity.mul( + 0.99 * + (-10015.14417168339605268557 * price ** 10 + + 46175.29901770254946313798 * price ** 9 - + 90642.29890720185358077288 * price ** 8 + + 98754.41788689797976985574 * price ** 7 - + 65270.04041910833620931953 * price ** 6 + + 26866.82939015745796496049 * price ** 5 - + 6805.08835771731082786573 * price ** 4 + + 1008.86080878299947016785 * price ** 3 - + 79.48474558820969093631 * price ** 2 + + 1.45265602009115950999 * price ** 1 + + 4.5837281790982524754), + ); +}; + +export const approximateMaxAmountInForSell = ( + reserve: Decimal, // amount of asset in the pool + liquidity: Decimal, // liqudity parameter of the pool +) => { + const price = calculateSpotPrice(reserve, liquidity).toNumber(); + + return liquidity.mul( + 0.99 * + (6027.48739001329704478849 * price ** 10 - + 23943.83771737971983384341 * price ** 9 + + 36748.94249659497290849686 * price ** 8 - + 24233.23433796403696760535 * price ** 7 + + 453.48665119856707406143 * price ** 6 + + 10032.97899602322468126658 * price ** 5 - + 7080.22041420203277084511 * price ** 4 + + 2410.0255212617116740148 * price ** 3 - + 459.95159954049660200326 * price ** 2 + + 54.14308593643040978804 * price ** 1 - + 0.538594836861739279), + ); +}; diff --git a/lib/util/parse-asset-id.ts b/lib/util/parse-asset-id.ts index e3a4dfc0a..4897f9a91 100644 --- a/lib/util/parse-asset-id.ts +++ b/lib/util/parse-asset-id.ts @@ -1,5 +1,7 @@ import { AssetId, parseAssetId } from "@zeitgeistpm/sdk"; -export const parseAssetIdString = (assetId?: string): AssetId | undefined => { +export const parseAssetIdString = ( + assetId?: string | AssetId, +): AssetId | undefined => { return assetId ? parseAssetId(assetId).unrightOr(undefined) : undefined; }; diff --git a/package.json b/package.json index ea88b2248..4d5e743c8 100644 --- a/package.json +++ b/package.json @@ -39,11 +39,11 @@ "@web3auth/modal": "^7.0.4", "@yornaath/batshit": "^0.7.1", "@yornaath/batshit-devtools-react": "^0.5.4", - "@zeitgeistpm/augment-api": "2.22.0", + "@zeitgeistpm/augment-api": "2.23.0", "@zeitgeistpm/avatara-nft-sdk": "^1.3.1", "@zeitgeistpm/avatara-react": "^1.3.2", "@zeitgeistpm/avatara-util": "^1.2.0", - "@zeitgeistpm/sdk": "2.46.0", + "@zeitgeistpm/sdk": "2.47.0", "@zeitgeistpm/utility": "^2.20.0", "axios": "^0.21.4", "boring-avatars": "^1.6.1", diff --git a/pages/markets/[marketid].tsx b/pages/markets/[marketid].tsx index 91c0132f8..b162cdadb 100644 --- a/pages/markets/[marketid].tsx +++ b/pages/markets/[marketid].tsx @@ -19,6 +19,7 @@ import CategoricalDisputeBox from "components/outcomes/CategoricalDisputeBox"; import CategoricalReportBox from "components/outcomes/CategoricalReportBox"; import ScalarDisputeBox from "components/outcomes/ScalarDisputeBox"; import ScalarReportBox from "components/outcomes/ScalarReportBox"; +import Amm2TradeForm from "components/trade-form/Amm2TradeForm"; import Skeleton from "components/ui/Skeleton"; import { ChartSeries } from "components/ui/TimeSeriesChart"; import Decimal from "decimal.js"; @@ -59,6 +60,8 @@ import { useEffect, useMemo, useState } from "react"; import { AlertTriangle, ChevronDown, X } from "react-feather"; import { AiOutlineFileAdd } from "react-icons/ai"; import { FaChevronDown, FaChevronUp } from "react-icons/fa"; +import { ScoringRule } from "@zeitgeistpm/indexer"; +import { TradeTabType } from "components/trade-form/TradeTab"; const TradeForm = dynamic(() => import("../../components/trade-form"), { ssr: false, @@ -246,6 +249,12 @@ const Market: NextPage = ({ return ; } + const marketHasPool = + (market?.scoringRule === ScoringRule.Cpmm && + poolId != null && + poolIdLoading === false) || + (market?.scoringRule === ScoringRule.Lmsr && market.neoPool != null); + return (
@@ -283,7 +292,7 @@ const Market: NextPage = ({ ) : ( <> )} - {poolId == null && poolIdLoading === false && ( + {marketHasPool && market.neoPool == null && (
@@ -334,7 +343,7 @@ const Market: NextPage = ({ )} - {market && !market.pool && ( + {market && !marketHasPool && ( = ({ - {market && (market?.pool || poolDeployed) && ( + {market && (marketHasPool || poolDeployed) && (
= ({ leave="transition ease-in duration-75" leaveFrom="transform opacity-100 " leaveTo="transform opacity-0 " - show={showLiquidity && Boolean(market?.pool || poolDeployed)} + show={showLiquidity && Boolean(marketHasPool || poolDeployed)} > @@ -380,7 +389,11 @@ const Market: NextPage = ({
{market?.status === MarketStatus.Active ? ( <> - + {market?.scoringRule === ScoringRule.Cpmm ? ( + + ) : ( + + )} ) : market?.status === MarketStatus.Closed && canReport ? ( <> @@ -450,7 +463,21 @@ const MobileContextButtons = ({ market }: { market: FullMarketFragment }) => { > {market?.status === MarketStatus.Active ? ( <> - + {market?.scoringRule === ScoringRule.Cpmm ? ( +
+ +
+ ) : ( + + )} ) : market?.status === MarketStatus.Closed && canReport ? ( <> diff --git a/yarn.lock b/yarn.lock index c5d373408..7be57d7e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4438,7 +4438,18 @@ __metadata: languageName: node linkType: hard -"@zeitgeistpm/augment-api@npm:2.22.0, @zeitgeistpm/augment-api@npm:^2.21.0, @zeitgeistpm/augment-api@npm:^2.22.0": +"@zeitgeistpm/augment-api@npm:2.23.0, @zeitgeistpm/augment-api@npm:^2.23.0": + version: 2.23.0 + resolution: "@zeitgeistpm/augment-api@npm:2.23.0" + peerDependencies: + "@polkadot/api-base": "*" + "@polkadot/rpc-core": "*" + "@polkadot/types": "*" + checksum: 482ea1c15b8c2e42325b730015979d16b369348307497d0a3c9451dee20aaa507e1dd1f6c0b241ca05c8d466bac075c0146ec917d96222e67c4df41abf213c79 + languageName: node + linkType: hard + +"@zeitgeistpm/augment-api@npm:^2.21.0": version: 2.22.0 resolution: "@zeitgeistpm/augment-api@npm:2.22.0" peerDependencies: @@ -4515,14 +4526,14 @@ __metadata: languageName: node linkType: hard -"@zeitgeistpm/indexer@npm:^3.17.0": - version: 3.17.0 - resolution: "@zeitgeistpm/indexer@npm:3.17.0" +"@zeitgeistpm/indexer@npm:^3.18.0": + version: 3.18.0 + resolution: "@zeitgeistpm/indexer@npm:3.18.0" dependencies: graphql: ^16.6.0 graphql-request: ^5.0.0 graphql-tag: ^2.12.6 - checksum: 5ac0556457dc50bb3eaea51eb77d727ad88bb34a775b4ca88ecc396a078ecd6705dfd6d022647b3fae592acea48f6f3ab6760c730fa8d3198c8700c25f773597 + checksum: befbc749a39fa3777fd7c7036056010322ee5270bef661bc72c0e69d52ca64a86cfea747a481d97b5cd7f43961863d12a658eb9eed853d140f9570d4c1bfd4da languageName: node linkType: hard @@ -4540,12 +4551,12 @@ __metadata: languageName: node linkType: hard -"@zeitgeistpm/sdk@npm:2.46.0": - version: 2.46.0 - resolution: "@zeitgeistpm/sdk@npm:2.46.0" +"@zeitgeistpm/sdk@npm:2.47.0": + version: 2.47.0 + resolution: "@zeitgeistpm/sdk@npm:2.47.0" dependencies: - "@zeitgeistpm/augment-api": ^2.22.0 - "@zeitgeistpm/indexer": ^3.17.0 + "@zeitgeistpm/augment-api": ^2.23.0 + "@zeitgeistpm/indexer": ^3.18.0 "@zeitgeistpm/rpc": ^2.14.0 "@zeitgeistpm/utility": ^2.24.0 "@zeitgeistpm/web3.storage": ^2.15.0 @@ -4562,7 +4573,7 @@ __metadata: "@polkadot/api": "*" "@polkadot/types": "*" "@polkadot/util": "*" - checksum: ea611d8b0b71cefb0e7cb893e0e93b18924639160ea1fff43a2a67b23e236bf1d6be55b42634ea0d98e549c6ec6dc38c2ac124d95e4c39949901b9294c0b06ef + checksum: ffcb1a82229a5f5f225262513f554d89ca642555a2311dacb38d17b44164a8c273e60080f0ee238ff43de869626d7c129981d093ab015ab23d9ca2bddd7ca3e4 languageName: node linkType: hard @@ -4620,11 +4631,11 @@ __metadata: "@web3auth/modal": ^7.0.4 "@yornaath/batshit": ^0.7.1 "@yornaath/batshit-devtools-react": ^0.5.4 - "@zeitgeistpm/augment-api": 2.22.0 + "@zeitgeistpm/augment-api": 2.23.0 "@zeitgeistpm/avatara-nft-sdk": ^1.3.1 "@zeitgeistpm/avatara-react": ^1.3.2 "@zeitgeistpm/avatara-util": ^1.2.0 - "@zeitgeistpm/sdk": 2.46.0 + "@zeitgeistpm/sdk": 2.47.0 "@zeitgeistpm/utility": ^2.20.0 autoprefixer: 10.2.5 axios: ^0.21.4