From 1224b3c8f1765cccd53ff2ba485e2218d3826fba Mon Sep 17 00:00:00 2001 From: David Uhlmann Date: Thu, 14 Mar 2024 23:31:17 +1000 Subject: [PATCH 01/35] #537 --- package-lock.json | 1 + package.json | 1 + src/lib/web3/clients/signingClients.ts | 74 ++++- src/lib/web3/hooks/useSWR.ts | 37 ++- src/pages/Swap/Swap.tsx | 435 +++++++++++++------------ src/pages/Swap/hooks/useRouter.ts | 109 ++++++- src/pages/Swap/hooks/useSwap.tsx | 6 +- 7 files changed, 433 insertions(+), 230 deletions(-) diff --git a/package-lock.json b/package-lock.json index 884610add..871464d51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@chain-registry/client": "^1.18.0", "@chain-registry/keplr": "^1.30.0", "@chain-registry/utils": "^1.17.0", + "@cosmjs/amino": "0.31.1", "@cosmjs/crypto": "0.31.1", "@cosmjs/proto-signing": "0.31.1", "@cosmjs/stargate": "0.31.1", diff --git a/package.json b/package.json index 6ef6d2f24..f3fa56cd8 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@chain-registry/client": "^1.18.0", "@chain-registry/keplr": "^1.30.0", "@chain-registry/utils": "^1.17.0", + "@cosmjs/amino": "0.31.1", "@cosmjs/crypto": "0.31.1", "@cosmjs/proto-signing": "0.31.1", "@cosmjs/stargate": "0.31.1", diff --git a/src/lib/web3/clients/signingClients.ts b/src/lib/web3/clients/signingClients.ts index 58d8d20f6..b1af62dc6 100644 --- a/src/lib/web3/clients/signingClients.ts +++ b/src/lib/web3/clients/signingClients.ts @@ -1,11 +1,19 @@ import useSWRImmutable from 'swr/immutable'; -import { SigningStargateClient } from '@cosmjs/stargate'; import { Tendermint34Client } from '@cosmjs/tendermint-rpc'; -import { GeneratedType, OfflineSigner } from '@cosmjs/proto-signing'; +import { GeneratedType, OfflineSigner, Registry } from '@cosmjs/proto-signing'; +import { encodeSecp256k1Pubkey } from '@cosmjs/amino'; +import { + StargateClient, + SigningStargateClient, + QueryClient, + setupTxExtension, + TxExtension, +} from '@cosmjs/stargate'; import { getSigningIbcClient, getSigningNeutronClient, getSigningNeutronClientOptions, + neutronProtoRegistry, } from '@duality-labs/neutronjs'; const { REACT_APP__RPC_API: defaultRpcEndpoint = '' } = import.meta.env; @@ -15,11 +23,71 @@ const { REACT_APP__RPC_API: defaultRpcEndpoint = '' } = import.meta.env; // because we already know that the native chain is at Tendermint37. // the base `getSigningNeutronClient` will make a network request to check function useTendermint37Client(rpcEndpoint: string) { - return useSWRImmutable(['rpc', rpcEndpoint], async () => { + return useSWRImmutable(['tmClient', rpcEndpoint], async () => { return Tendermint34Client.connect(rpcEndpoint); }).data; } +type TxSimulationClient = { + // use StargateClient simulate parameters but return full message not just gas + simulate: ( + ...args: Parameters + ) => ReturnType; +}; +export class TxSimulationError extends Error { + constructor(error: unknown) { + // parse messages to handle any possible library excpetions + const message = (error as Error)?.message || `${error}`; + super(message); + this.name = 'TxSimulationError'; + } +} + +export function useTxSimulationClient( + signer: OfflineSigner | null, + rpcEndpoint = defaultRpcEndpoint, + defaultTypes: ReadonlyArray<[string, GeneratedType]> = neutronProtoRegistry +) { + const tmClient = useTendermint37Client(rpcEndpoint); + return useSWRImmutable( + tmClient && signer ? ['queryClient', rpcEndpoint] : null, + async (): Promise => { + // early return null condition keys to assist types in this function + if (!(tmClient && signer)) return; + try { + const stargateClient = await StargateClient.create(tmClient); + const stargateQueryClient = new QueryClient(tmClient); + const stargateQueryTxClient = setupTxExtension(stargateQueryClient); + const registry = new Registry(defaultTypes); + return { + simulate: async function simulate(signerAddress, messages, memo) { + const anyMsgs = messages.map((m) => registry.encodeAsAny(m)); + const accountFromSigner = (await signer.getAccounts()).find( + (account) => account.address === signerAddress + ); + if (!accountFromSigner) { + throw new Error('Failed to retrieve account from signer'); + } + + const pubkey = encodeSecp256k1Pubkey(accountFromSigner.pubkey); + const { sequence } = await stargateClient.getSequence( + signerAddress + ); + return await stargateQueryTxClient.tx.simulate( + anyMsgs, + memo, + pubkey, + sequence + ); + }, + }; + } catch (error) { + throw new TxSimulationError(error); + } + } + ).data; +} + // hook for signing client without Tendermint34 lookup: we know its Tendermint37 export function useDexSigningClient( signer?: OfflineSigner, diff --git a/src/lib/web3/hooks/useSWR.ts b/src/lib/web3/hooks/useSWR.ts index 61c592270..ac5a94190 100644 --- a/src/lib/web3/hooks/useSWR.ts +++ b/src/lib/web3/hooks/useSWR.ts @@ -1,49 +1,58 @@ -import { UseQueryResult } from '@tanstack/react-query'; +import { RefetchOptions, UseQueryResult } from '@tanstack/react-query'; import { useCallback, useMemo, useRef } from 'react'; import { SWRResponse } from 'swr'; export type SWRCommon = Omit< SWRResponse, 'mutate' ->; +> & { refetch?: (opts?: RefetchOptions) => void }; interface SWRCommonWithRequiredData extends SWRCommon { data: Data; } -export function useSwrResponse( - data: T | undefined, - swr1: Omit, - swr2?: Omit -): SWRCommon { +export function useSwrResponse( + data: Data | undefined, + swr1: Omit, 'data'>, + swr2?: Omit, 'data'> +): SWRCommon { return useMemo(() => { return { isLoading: !!(swr1.isLoading || swr2?.isLoading), isValidating: !!(swr1.isValidating || swr2?.isValidating), error: swr1.error || swr2?.error, + refetch: swr1.refetch || swr2?.refetch, data, }; }, [ - data, swr1.isLoading, swr1.isValidating, swr1.error, + swr1.refetch, swr2?.isLoading, swr2?.isValidating, swr2?.error, + swr2?.refetch, + data, ]); } -export function useSwrResponseFromReactQuery( - data: T | undefined, - queryResult1: Omit, - queryResult2?: Omit -): SWRCommon { +type QueryResultCommon = Pick< + UseQueryResult, + 'isPending' | 'isFetching' | 'error' +> & { refetch: (opts?: RefetchOptions) => unknown }; + +export function useSwrResponseFromReactQuery( + data: Data | undefined, + queryResult1: QueryResultCommon, + queryResult2?: QueryResultCommon +): SWRCommon { const swr1 = useMemo( () => ({ isLoading: queryResult1.isPending, isValidating: queryResult1.isFetching, error: queryResult1.error || undefined, + refetch: queryResult1.refetch, }), [queryResult1] ); @@ -52,6 +61,7 @@ export function useSwrResponseFromReactQuery( isLoading: !!queryResult2?.isPending, isValidating: !!queryResult2?.isFetching, error: queryResult2?.error || undefined, + refetch: queryResult2?.refetch, }), [queryResult2] ); @@ -76,6 +86,7 @@ export function useCombineResults(): ( isLoading: results.every((result) => result.isPending), isValidating: results.some((result) => result.isFetching), error: results.find((result) => result.error)?.error ?? undefined, + refetch: results.find((result) => result.refetch)?.refetch ?? undefined, }; }, []); } diff --git a/src/pages/Swap/Swap.tsx b/src/pages/Swap/Swap.tsx index 0472d3c93..59dcd0f73 100644 --- a/src/pages/Swap/Swap.tsx +++ b/src/pages/Swap/Swap.tsx @@ -10,7 +10,6 @@ import { faSliders, faXmark, } from '@fortawesome/free-solid-svg-icons'; -import { LimitOrderType } from '@duality-labs/neutronjs/types/codegen/neutron/dex/tx'; import TokenInputGroup from '../../components/TokenInputGroup'; import { @@ -25,23 +24,33 @@ import PriceDataDisclaimer from '../../components/PriceDataDisclaimer'; import { useWeb3 } from '../../lib/web3/useWeb3'; import { useBankBalanceDisplayAmount } from '../../lib/web3/hooks/useUserBankBalances'; -import { useOrderedTokenPair } from '../../lib/web3/hooks/useTokenPairs'; -import { useTokenPairTickLiquidity } from '../../lib/web3/hooks/useTickLiquidity'; import { useToken } from '../../lib/web3/hooks/useDenomClients'; +import { useCurrentPriceFromTicks } from '../../components/Liquidity/useCurrentPriceFromTicks'; -import { getRouterEstimates, useRouterResult } from './hooks/useRouter'; +import { useSimulatedLimitOrderResult } from './hooks/useRouter'; import { useSwap } from './hooks/useSwap'; import { formatPercentage } from '../../lib/utils/number'; import { Token, getBaseDenomAmount, + getDisplayDenomAmount, getTokenId, } from '../../lib/web3/utils/tokens'; import { formatLongPrice } from '../../lib/utils/number'; +import { orderTypeEnum } from '../../lib/web3/utils/limitOrders'; +import { + DexTickUpdateEvent, + mapEventAttributes, +} from '../../lib/web3/utils/events'; +import { tickIndexToPrice } from '../../lib/web3/utils/ticks'; import './Swap.scss'; +const { REACT_APP__MAX_TICK_INDEXES = '' } = import.meta.env; +const [, priceMaxIndex = Number.MAX_SAFE_INTEGER] = + `${REACT_APP__MAX_TICK_INDEXES}`.split(',').map(Number).filter(Boolean); + type CardType = 'trade' | 'settings'; type OrderType = 'market' | 'limit'; @@ -103,29 +112,68 @@ function Swap() { const [inputValueA, setInputValueA, valueA = '0'] = useNumericInputState(); const [inputValueB, setInputValueB, valueB = '0'] = useNumericInputState(); const [lastUpdatedA, setLastUpdatedA] = useState(true); - const pairRequest = { - tokenA: getTokenId(tokenA), - tokenB: getTokenId(tokenB), - valueA: lastUpdatedA ? valueA : undefined, - valueB: lastUpdatedA ? undefined : valueB, - }; - const { - data: routerResult, - isValidating: isValidatingRate, - error, - } = useRouterResult({ - tokenA: getTokenId(tokenA), - tokenB: getTokenId(tokenB), - valueA: lastUpdatedA ? valueA : undefined, - valueB: lastUpdatedA ? undefined : valueB, - }); - - const rateData = getRouterEstimates(pairRequest, routerResult); + const denoms = [denomA, denomB].filter((denom): denom is string => !!denom); const [{ isValidating: isValidatingSwap }, swapRequest] = useSwap(denoms); - const valueAConverted = lastUpdatedA ? valueA : rateData?.valueA; - const valueBConverted = lastUpdatedA ? rateData?.valueB : valueB; + const { data: balanceTokenA } = useBankBalanceDisplayAmount(denomA); + + // update simulation whenever price changes + // todo: replace with something more lightweight if reliably available + // eg. using the stats endpoint of last known price of last 48 hours + // which will require reasonable handling when recent price is unknown + const currentPriceFromTicks = useCurrentPriceFromTicks(denomA, denomB); + + // create reusable swap msg + const swapMsg = useMemo(() => { + const amountIn = tokenA && Number(getBaseDenomAmount(tokenA, valueA)); + if (address && denomA && denomB && currentPriceFromTicks) { + return { + amount_in: (amountIn || 0).toFixed(0), + token_in: denomA, + token_out: denomB, + creator: address, + receiver: address, + // using type FILL_OR_KILL so that partially filled requests fail + // note: using IMMEDIATE_OR_CANCEL while FILL_OR_KILL has a bug + // that often can result in rounding errors and incomplete orders + // revert this (non-squashed commit) when it is fixed + order_type: orderTypeEnum.IMMEDIATE_OR_CANCEL, + // trade as far as we can go + tick_index_in_to_out: Long.fromNumber(priceMaxIndex), + }; + } + }, [address, denomA, denomB, tokenA, valueA, currentPriceFromTicks]); + + // simulate trade with swap msg + const { + data: simulationResult, + isValidating: isValidatingRate, + error: simulationError = simulationResult?.error, + refetch: simulationRefetch, + } = useSimulatedLimitOrderResult(swapMsg); + + const rate = + simulationResult?.response && + new BigNumber(simulationResult.response.taker_coin_out.amount).dividedBy( + simulationResult.response.coin_in.amount + ); + const valueAConverted = lastUpdatedA + ? valueA + : tokenA && + tokenB && + getDisplayDenomAmount( + tokenA, + getBaseDenomAmount(tokenB, rate?.multipliedBy(valueB) || 0) || 0 + ); + const valueBConverted = lastUpdatedA + ? tokenA && + tokenB && + getDisplayDenomAmount( + tokenB, + getBaseDenomAmount(tokenA, rate?.multipliedBy(valueA) || 0) || 0 + ) + : valueB; const swapTokens = useCallback( function () { @@ -145,7 +193,6 @@ function Swap() { ] ); - const { data: balanceTokenA } = useBankBalanceDisplayAmount(denomA); const valueAConvertedNumber = new BigNumber(valueAConverted || 0); const hasFormData = address && tokenA && tokenB && valueAConvertedNumber.isGreaterThan(0); @@ -155,114 +202,87 @@ function Swap() { const [inputSlippage, setInputSlippage, slippage = '0'] = useNumericInputState(defaultSlippage); - const [token0, token1] = - useOrderedTokenPair([getTokenId(tokenA), getTokenId(tokenB)]) || []; - const { - data: [token0Ticks, token1Ticks], - } = useTokenPairTickLiquidity([token0, token1]); + const gasEstimate = simulationResult?.gasInfo?.gasUsed.toNumber(); + + const tickUpdateEvents = useMemo(() => { + // calculate ordered tick updates from result events + return simulationResult?.result?.events + .filter((event) => event.type === 'TickUpdate') + .map(mapEventAttributes) + .filter( + (event): event is DexTickUpdateEvent => + event.type === 'TickUpdate' && event.attributes.TokenIn === denomA + ) + .sort( + (a, b) => + Number(a.attributes.TickIndex) - Number(b.attributes.TickIndex) + ); + }, [denomA, simulationResult?.result?.events]); + + const tickIndexLimitInToOut = useMemo(() => { + // calculate last price out from result + const lastPrice = tickUpdateEvents?.at(-1)?.attributes; + if (lastPrice) { + const direction = + lastPrice.TokenIn === denomA + ? lastPrice.TokenIn === lastPrice.TokenZero + : lastPrice.TokenIn === lastPrice.TokenOne; + + const tolerance = Math.max(1e-12, parseFloat(slippage) / 100); + const toleranceFactor = 1 + tolerance; + return direction + ? Math.floor(Number(lastPrice.TickIndex) / toleranceFactor) + : Math.floor(Number(lastPrice.TickIndex) * toleranceFactor); + } + }, [denomA, slippage, tickUpdateEvents]); const onFormSubmit = useCallback( - function (event?: React.FormEvent) { + async function (event?: React.FormEvent) { if (event) event.preventDefault(); // calculate tolerance from user slippage settings // set tiny minimum of tolerance as the frontend calculations // don't always exactly align with the backend calculations const tolerance = Math.max(1e-12, parseFloat(slippage) / 100); - const tickIndexLimit = routerResult?.tickIndexOut?.toNumber(); + const toleranceFactor = 1 + tolerance; + const amountIn = tokenA && Number(getBaseDenomAmount(tokenA, valueA)); if ( - address && - routerResult && - tokenA && - tokenB && - !isNaN(tolerance) && - tickIndexLimit !== undefined && - !isNaN(tickIndexLimit) + swapMsg && + amountIn && + simulationResult?.response?.taker_coin_out.amount && + Number(simulationResult.response.taker_coin_out.amount) > 0 && + tickIndexLimitInToOut && + gasEstimate ) { + const amountOut = new BigNumber( + simulationResult.response.taker_coin_out.amount + ).multipliedBy(toleranceFactor); // convert to swap request format - const result = routerResult; - // Cosmos requires tokens in integer format of smallest denomination - // calculate gas estimate - const tickMin = - routerResult.tickIndexIn && - routerResult.tickIndexOut && - Math.min( - routerResult.tickIndexIn.toNumber(), - routerResult.tickIndexOut.toNumber() - ); - const tickMax = - routerResult.tickIndexIn && - routerResult.tickIndexOut && - Math.max( - routerResult.tickIndexIn.toNumber(), - routerResult.tickIndexOut.toNumber() - ); - const forward = result.tokenIn === token0; - const ticks = forward ? token1Ticks : token0Ticks; - const ticksPassed = - (tickMin !== undefined && - tickMax !== undefined && - ticks?.filter((tick) => { - return ( - tick.tickIndex1To0.isGreaterThanOrEqualTo(tickMin) && - tick.tickIndex1To0.isLessThanOrEqualTo(tickMax) - ); - })) || - []; - const ticksUsed = - ticksPassed?.filter( - forward - ? (tick) => !tick.reserve1.isZero() - : (tick) => !tick.reserve0.isZero() - ).length || 0; - const ticksUnused = - new Set([ - ...(ticksPassed?.map((tick) => tick.tickIndex1To0.toNumber()) || - []), - ]).size - ticksUsed; - const gasEstimate = ticksUsed - ? // 120000 base - 120000 + - // add 80000 if multiple ticks need to be traversed - (ticksUsed > 1 ? 80000 : 0) + - // add 1000000 for each tick that we need to remove liquidity from - 1000000 * (ticksUsed - 1) + - // add 500000 for each tick we pass without drawing liquidity from - 500000 * ticksUnused + - // add another 500000 for each reverse tick we pass without drawing liquidity from - (forward ? 0 : 500000 * ticksUnused) - : 0; - - swapRequest( + await swapRequest( { - amount_in: getBaseDenomAmount(tokenA, result.amountIn) || '0', - token_in: result.tokenIn, - token_out: result.tokenOut, - creator: address, - receiver: address, - // see LimitOrderType in types repo (cannot import at runtime) + ...swapMsg, // using type FILL_OR_KILL so that partially filled requests fail - order_type: 1 as LimitOrderType.FILL_OR_KILL, - // todo: set tickIndex to allow for a tolerance: - // the below function is a tolerance of 0 - tick_index_in_to_out: Long.fromNumber( - tickIndexLimit * (forward ? 1 : -1) - ), - max_amount_out: getBaseDenomAmount(tokenB, result.amountOut) || '0', + // note: using IMMEDIATE_OR_CANCEL while FILL_OR_KILL has a bug + // that often can result in rounding errors and incomplete orders + // revert this (non-squashed commit) when it is fixed + order_type: orderTypeEnum.IMMEDIATE_OR_CANCEL, + tick_index_in_to_out: Long.fromNumber(tickIndexLimitInToOut), + max_amount_out: amountOut.toFixed(0), }, gasEstimate ); + simulationRefetch?.(); } }, [ - address, - routerResult, - tokenA, - tokenB, - token0, - token0Ticks, - token1Ticks, slippage, + tokenA, + valueA, + swapMsg, + simulationResult?.response?.taker_coin_out.amount, + tickIndexLimitInToOut, + gasEstimate, swapRequest, + simulationRefetch, ] ); @@ -327,16 +347,20 @@ function Swap() { } }, [tokenA, tokenB]); - const priceImpact = - routerResult && - routerResult.priceBToAIn?.isGreaterThan(0) && - routerResult.priceBToAOut?.isGreaterThan(0) - ? new BigNumber( - new BigNumber(routerResult.priceBToAIn).dividedBy( - new BigNumber(routerResult.priceBToAOut) - ) - ).minus(1) - : undefined; + const priceImpact = useMemo(() => { + // calculate first and last prices out from sorted result events + const firstPriceEvent = tickUpdateEvents?.at(0); + const lastPriceEvent = tickUpdateEvents?.at(-1); + if (firstPriceEvent && lastPriceEvent) { + const firstPrice = tickIndexToPrice( + new BigNumber(firstPriceEvent.attributes.TickIndex) + ); + const lastPrice = tickIndexToPrice( + new BigNumber(lastPriceEvent.attributes.TickIndex) + ); + return firstPrice.dividedBy(lastPrice).minus(1); + } + }, [tickUpdateEvents]); const tradeCard = (
@@ -384,26 +408,19 @@ function Swap() {
- - ) : isValidatingRate ? ( - 'Finding exchange rate...' - ) : ( - 'No exchange information' - )} - - Price Impact - {priceImpact && ( - { - switch (true) { - case priceImpact.isGreaterThanOrEqualTo(0): - return 'text-success'; - case priceImpact.isGreaterThan(-0.01): - return 'text-value'; - case priceImpact.isGreaterThan(-0.05): - return 'text'; - default: - return 'text-error'; - } - })(), - ].join(' ')} - > - {formatPercentage(priceImpact.toFixed(), { - maximumSignificantDigits: 4, - minimumSignificantDigits: 4, - })} - + {tokenA && tokenB && Number(inputValueA) > 0 && ( +
+ Exchange Rate + + {simulationResult?.response && rateTokenOrder ? ( + <> + 1 {rateTokenOrder[1].symbol} ={' '} + {formatLongPrice( + simulationResult.response.coin_in.denom === + getTokenId(rateTokenOrder[1]) + ? new BigNumber( + simulationResult.response.taker_coin_out.amount + ) + .dividedBy(simulationResult.response.coin_in.amount) + .toFixed() + : new BigNumber( + simulationResult.response.coin_in.amount + ) + .dividedBy( + simulationResult.response.taker_coin_out.amount + ) + .toFixed() + )}{' '} + {rateTokenOrder[0].symbol} + + + ) : isValidatingRate ? ( + 'Finding exchange rate...' + ) : ( + 'No exchange information' )} -
- )} + + Price Impact + {priceImpact && ( + { + switch (true) { + case priceImpact.isGreaterThanOrEqualTo(0): + return 'text-success'; + case priceImpact.isGreaterThan(-0.01): + return 'text-value'; + case priceImpact.isGreaterThan(-0.05): + return 'text'; + default: + return 'text-error'; + } + })(), + ].join(' ')} + > + {formatPercentage(priceImpact.toFixed(), { + maximumSignificantDigits: 4, + minimumSignificantDigits: 4, + })} + + )} +
+ )}
{address ? ( hasFormData && hasSufficientFunds && - !error?.insufficientLiquidity && - !error?.insufficientLiquidityIn && - !error?.insufficientLiquidityOut ? ( + !simulationError?.insufficientLiquidity ? ( - ) : error?.insufficientLiquidity ? ( + ) : simulationError?.insufficientLiquidity ? ( diff --git a/src/pages/Swap/hooks/useRouter.ts b/src/pages/Swap/hooks/useRouter.ts index 6bf57ec3f..3251e9df4 100644 --- a/src/pages/Swap/hooks/useRouter.ts +++ b/src/pages/Swap/hooks/useRouter.ts @@ -1,19 +1,34 @@ +import BigNumber from 'bignumber.js'; import { useEffect, useState } from 'react'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { TxExtension } from '@cosmjs/stargate'; +import { neutron } from '@duality-labs/neutronjs'; +import type { + MsgPlaceLimitOrder, + MsgPlaceLimitOrderResponse, +} from '@duality-labs/neutronjs/types/codegen/neutron/dex/tx'; + import { PairRequest, PairResult, RouterResult } from './index'; import { routerAsync, calculateFee, SwapError } from './router'; -import { formatMaximumSignificantDecimals } from '../../../lib/utils/number'; -import BigNumber from 'bignumber.js'; +import { formatMaximumSignificantDecimals } from '../../../lib/utils/number'; +import { TickInfo } from '../../../lib/web3/utils/ticks'; +import { getPairID } from '../../../lib/web3/utils/pairs'; import { getBaseDenomAmount, getDisplayDenomAmount, } from '../../../lib/web3/utils/tokens'; +import { useWeb3 } from '../../../lib/web3/useWeb3'; +import { + useTxSimulationClient, + TxSimulationError, +} from '../../../lib/web3/clients/signingClients'; + import { useToken } from '../../../lib/web3/hooks/useDenomClients'; import { useTokenPairTickLiquidity } from '../../../lib/web3/hooks/useTickLiquidity'; import { useOrderedTokenPair } from '../../../lib/web3/hooks/useTokenPairs'; -import { TickInfo } from '../../../lib/web3/utils/ticks'; -import { getPairID } from '../../../lib/web3/utils/pairs'; +import { useSwrResponseFromReactQuery } from '../../../lib/web3/hooks/useSWR'; const cachedRequests: { [token0: string]: { [token1: string]: PairResult }; @@ -43,6 +58,92 @@ async function getRouterResult( } } +type SimulateResponse = Awaited>; +type ExtendedSimulateResponse = Partial< + SimulateResponse & { + response: MsgPlaceLimitOrderResponse; + error: LimitOrderTxSimulationError; + } +>; + +const FILL_OR_KILL_ERROR = + // eslint-disable-next-line quotes + "Fill Or Kill limit order couldn't be executed in its entirety"; +class LimitOrderTxSimulationError extends TxSimulationError { + insufficientLiquidity: boolean; + constructor(error: unknown) { + super(error); + this.name = 'LimitOrderTxSimulationError'; + + // parse out message codes + this.insufficientLiquidity = this.message.includes(FILL_OR_KILL_ERROR); + } +} + +/** + * Gets the simulated results and gas usage of a limit order transaction + * @param msgPlaceLimitOrder the MsgPlaceLimitOrder request + * @param memo optional memo string to add + * @returns aync request state and data of { gasInfo, txResult, msgResponse } + */ +export function useSimulatedLimitOrderResult( + msgPlaceLimitOrder: MsgPlaceLimitOrder | undefined, + memo = '' +) { + // use signing client simulation function to get simulated response and gas + const { wallet, address } = useWeb3(); + const txSimulationClient = useTxSimulationClient(wallet); + const result = useQuery< + ExtendedSimulateResponse | undefined, + LimitOrderTxSimulationError + >({ + queryKey: [txSimulationClient, address, JSON.stringify(msgPlaceLimitOrder)], + enabled: Boolean(txSimulationClient && address && msgPlaceLimitOrder), + queryFn: async (): Promise => { + // early exit to help types, should match "enabled" property condition + if (!(txSimulationClient && address && msgPlaceLimitOrder)) return; + // return empty response for a zero amount query + if (!Number(msgPlaceLimitOrder.amount_in)) return {}; + try { + const { gasInfo, result } = await txSimulationClient.simulate( + address, + [ + neutron.dex.MessageComposer.withTypeUrl.placeLimitOrder( + msgPlaceLimitOrder + ), + ], + memo + ); + // return successful response + if (result && result.msgResponses.length > 0) { + const response = neutron.dex.MsgPlaceLimitOrderResponse.decode( + result.msgResponses[0].value + ); + // add liquidity error if appropriate + const error = new BigNumber(response.coin_in.amount) + .multipliedBy(1.01) + .isLessThan(msgPlaceLimitOrder.amount_in) + ? new LimitOrderTxSimulationError(FILL_OR_KILL_ERROR) + : undefined; + return { gasInfo, result, response, error }; + } + // likely an error result + return { gasInfo, result }; + } catch (error) { + // return error so that it may be persisted + return { error: new LimitOrderTxSimulationError(error) }; + } + }, + // persist results (with error in error key) + placeholderData: keepPreviousData, + }); + + return useSwrResponseFromReactQuery< + ExtendedSimulateResponse | undefined, + LimitOrderTxSimulationError + >(result.data, result); +} + /** * Gets the estimated info of a swap transaction * @param pairRequest the respective IDs and value diff --git a/src/pages/Swap/hooks/useSwap.tsx b/src/pages/Swap/hooks/useSwap.tsx index 3affe7c44..d7d47b6cb 100644 --- a/src/pages/Swap/hooks/useSwap.tsx +++ b/src/pages/Swap/hooks/useSwap.tsx @@ -93,7 +93,7 @@ export function useSwap(denoms: string[]): [ isValidating: boolean; error?: string; }, - (request: MsgPlaceLimitOrder, gasEstimate: number) => void + (request: MsgPlaceLimitOrder, gasEstimate: number) => Promise ] { const [data, setData] = useState(); const [validating, setValidating] = useState(false); @@ -103,7 +103,7 @@ export function useSwap(denoms: string[]): [ const { data: tokenByDenom } = useTokenByDenom(denoms); const sendRequest = useCallback( - (request: MsgPlaceLimitOrder, gasEstimate: number) => { + async (request: MsgPlaceLimitOrder, gasEstimate: number) => { if (!request) return onError('Missing Tokens and value'); if (!web3) return onError('Missing Provider'); const { @@ -142,7 +142,7 @@ export function useSwap(denoms: string[]): [ if (!tokenInToken) return onError('Token in was not found'); if (!tokenOutToken) return onError('Token out was not found'); - createTransactionToasts( + await createTransactionToasts( () => { return sendSwap({ wallet, address }, request, gasEstimate); }, From babeb920c01ccd660d848584487aa0ee0c8002cf Mon Sep 17 00:00:00 2001 From: David Uhlmann Date: Fri, 15 Mar 2024 01:05:40 +1000 Subject: [PATCH 02/35] #541 --- .env.beta | 1 - src/components/Header/routes.ts | 8 +- src/components/cards/LimitOrderCard.tsx | 734 +++++++++++++-------- src/components/cards/LimitOrderContext.tsx | 16 +- src/lib/web3/utils/limitOrders.ts | 7 + src/pages/App/App.tsx | 6 +- src/pages/Swap/Swap.tsx | 2 +- src/pages/Swap/hooks/index.d.ts | 42 -- src/pages/Swap/hooks/router.ts | 228 ------- src/pages/Swap/hooks/useRouter.ts | 256 +------ 10 files changed, 480 insertions(+), 820 deletions(-) delete mode 100644 src/pages/Swap/hooks/index.d.ts delete mode 100644 src/pages/Swap/hooks/router.ts diff --git a/.env.beta b/.env.beta index 24e75f979..9ee19e504 100644 --- a/.env.beta +++ b/.env.beta @@ -6,7 +6,6 @@ REACT_APP__CHAIN_PRETTY_NAME=Neutron # App settings REACT_APP__DEFAULT_PAIR=NEWT/NTRN -REACT_APP__HIDE_ORDERBOOK=1 # Chain data sources REACT_APP__INDEXER_API=https://indexer.beta.duality.xyz diff --git a/src/components/Header/routes.ts b/src/components/Header/routes.ts index 523a39e6b..35a2e0e9c 100644 --- a/src/components/Header/routes.ts +++ b/src/components/Header/routes.ts @@ -1,13 +1,9 @@ -const { REACT_APP__DEFAULT_PAIR = '', REACT_APP__HIDE_ORDERBOOK = '' } = - import.meta.env; +const { REACT_APP__DEFAULT_PAIR = '' } = import.meta.env; export const pageLinkMap = { [['/swap', REACT_APP__DEFAULT_PAIR].join('/')]: 'Swap', '/pools': 'Pools', - // conditionally add the orderbook in - ...(!REACT_APP__HIDE_ORDERBOOK && { - [['/orderbook', REACT_APP__DEFAULT_PAIR].join('/')]: 'Orderbook', - }), + [['/orderbook', REACT_APP__DEFAULT_PAIR].join('/')]: 'Orderbook', '/portfolio': 'Portfolio', '/bridge': 'Bridge', 'https://duality.gitbook.io/duality-documentation/user-interface': 'Docs', diff --git a/src/components/cards/LimitOrderCard.tsx b/src/components/cards/LimitOrderCard.tsx index 2f3f1df99..fc136e75b 100644 --- a/src/components/cards/LimitOrderCard.tsx +++ b/src/components/cards/LimitOrderCard.tsx @@ -4,10 +4,15 @@ import { createContext, useCallback, useContext, + useEffect, useMemo, useRef, useState, } from 'react'; +import type { + LimitOrderType, + MsgPlaceLimitOrder, +} from '@duality-labs/neutronjs/types/codegen/neutron/dex/tx'; import TabsCard from './TabsCard'; import Tabs from '../Tabs'; @@ -27,12 +32,16 @@ import { import './LimitOrderCard.scss'; import Tooltip from '../Tooltip'; import { useSwap } from '../../pages/Swap/hooks/useSwap'; -import { useRouterResult } from '../../pages/Swap/hooks/useRouter'; +import { useSimulatedLimitOrderResult } from '../../pages/Swap/hooks/useRouter'; import { useWeb3 } from '../../lib/web3/useWeb3'; -import { useOrderedTokenPair } from '../../lib/web3/hooks/useTokenPairs'; -import { useTokenPairTickLiquidity } from '../../lib/web3/hooks/useTickLiquidity'; -import { useBankBalanceDisplayAmount } from '../../lib/web3/hooks/useUserBankBalances'; +import { + useBankBalanceBaseAmount, + useBankBalanceDisplayAmount, +} from '../../lib/web3/hooks/useUserBankBalances'; import { useChainFeeToken } from '../../lib/web3/hooks/useTokens'; +import { useNativeChain } from '../../lib/web3/hooks/useChains'; +import { useCurrentPriceFromTicks } from '../Liquidity/useCurrentPriceFromTicks'; + import RangeListSliderInput from '../inputs/RangeInput/RangeListSliderInput'; import { LimitOrderContextProvider, @@ -41,19 +50,40 @@ import { } from './LimitOrderContext'; import SelectInput from '../inputs/SelectInput'; import { timeUnits } from '../../lib/utils/time'; -import { displayPriceToTickIndex } from '../../lib/web3/utils/ticks'; import { - orderTypeTextMap, + inputOrderTypeTextMap, orderTypeEnum, timePeriods, timePeriodLabels, TimePeriod, AllowedLimitOrderTypeKey, } from '../../lib/web3/utils/limitOrders'; +import { + DexTickUpdateEvent, + mapEventAttributes, +} from '../../lib/web3/utils/events'; +import { displayPriceToTickIndex } from '../../lib/web3/utils/ticks'; import Drawer from '../Drawer'; +const { REACT_APP__MAX_TICK_INDEXES = '' } = import.meta.env; +const [, priceMaxIndex = Number.MAX_SAFE_INTEGER] = + `${REACT_APP__MAX_TICK_INDEXES}`.split(',').map(Number).filter(Boolean); + const defaultExecutionType: AllowedLimitOrderTypeKey = 'FILL_OR_KILL'; + +function formatNumericAmount(defaultValue = '') { + return (amount: number | string) => { + return amount + ? formatAmount( + amount, + { useGrouping: false }, + { reformatSmallValues: false } + ) + : defaultValue; + }; +} + const TabContext = createContext< [ tabIndex?: number, @@ -138,292 +168,459 @@ function LimitOrder({ tokenB, sell: sellMode = false, showLimitPrice = false, - showTriggerPrice = false, }: { tokenA?: Token; tokenB?: Token; sell?: boolean; showLimitPrice?: boolean; - showTriggerPrice?: boolean; }) { const buyMode = !sellMode; - const [tokenIdA, tokenIdB] = [getTokenId(tokenA), getTokenId(tokenB)]; - const [tokenId0, tokenId1] = useOrderedTokenPair([tokenIdA, tokenIdB]) || []; - const { - data: [token0Ticks, token1Ticks], - } = useTokenPairTickLiquidity([tokenId0, tokenId1]); + const [denomA, denomB] = [getTokenId(tokenA), getTokenId(tokenB)]; const formState = useContext(LimitOrderFormContext); const formSetState = useContext(LimitOrderFormSetContext); const tokenIn = !buyMode ? tokenA : tokenB; const tokenOut = buyMode ? tokenA : tokenB; - const { - data: userTokenInDisplayAmount, - isValidating: isLoadingUserTokenInDisplayAmount, - } = useBankBalanceDisplayAmount(tokenIn?.base); - const { - data: userTokenOutDisplayAmount, - isValidating: isLoadingUserTokenOutDisplayAmount, - } = useBankBalanceDisplayAmount(tokenOut?.base); + const [denomIn, denomOut] = [getTokenId(tokenIn), getTokenId(tokenOut)]; + const { data: userBalanceTokenIn, isLoading: isLoadingUserBalanceTokenIn } = + useBankBalanceBaseAmount(denomIn); + const { data: userBalanceTokenInDisplayAmount } = + useBankBalanceDisplayAmount(denomIn); const [{ isValidating: isValidatingSwap, error }, swapRequest] = useSwap( - [tokenIdA, tokenIdB].filter((denom): denom is string => !!denom) + [denomA, denomB].filter((denom): denom is string => !!denom) ); - const { data: routerResult } = useRouterResult({ - tokenA: getTokenId(tokenIn), - tokenB: getTokenId(tokenOut), - valueA: formState.amount, - valueB: undefined, - }); const { address, connectWallet } = useWeb3(); - const gasEstimate = useMemo(() => { - if (routerResult) { - // convert to swap request format - const result = routerResult; - // Cosmos requires tokens in integer format of smallest denomination - // calculate gas estimate - const tickMin = - routerResult.tickIndexIn && - routerResult.tickIndexOut && - Math.min( - routerResult.tickIndexIn.toNumber(), - routerResult.tickIndexOut.toNumber() - ); - const tickMax = - routerResult.tickIndexIn && - routerResult.tickIndexOut && - Math.max( - routerResult.tickIndexIn.toNumber(), - routerResult.tickIndexOut.toNumber() - ); - const forward = result.tokenIn === tokenId0; - const ticks = forward ? token1Ticks : token0Ticks; - const ticksPassed = - (tickMin !== undefined && - tickMax !== undefined && - ticks?.filter((tick) => { - return ( - tick.tickIndex1To0.isGreaterThanOrEqualTo(tickMin) && - tick.tickIndex1To0.isLessThanOrEqualTo(tickMax) - ); - })) || - []; - const ticksUsed = - ticksPassed?.filter( - forward - ? (tick) => !tick.reserve1.isZero() - : (tick) => !tick.reserve0.isZero() - ).length || 0; - const ticksUnused = - new Set([ - ...(ticksPassed?.map((tick) => tick.tickIndex1To0.toNumber()) || []), - ]).size - ticksUsed; - const gasEstimate = ticksUsed - ? // 120000 base - 120000 + - // add 80000 if multiple ticks need to be traversed - (ticksUsed > 1 ? 80000 : 0) + - // add 1000000 for each tick that we need to remove liquidity from - 1000000 * (ticksUsed - 1) + - // add 500000 for each tick we pass without drawing liquidity from - 500000 * ticksUnused + - // add another 500000 for each reverse tick we pass without drawing liquidity from - (forward ? 0 : 500000 * ticksUnused) - : 0; - return gasEstimate; + const [tokenInBalanceFraction, setTokenInBalanceFraction] = + useState(); + + const buyAmountSimulatedMsgPlaceLimitOrder = useMemo< + MsgPlaceLimitOrder | undefined + >(() => { + if (address && userBalanceTokenIn && tokenInBalanceFraction !== undefined) { + const [denomIn, denomOut] = [getTokenId(tokenIn), getTokenId(tokenOut)]; + if ( + denomIn && + denomOut && + userBalanceTokenIn && + Number(userBalanceTokenIn) > 0 && + tokenInBalanceFraction > 0 + ) { + return { + amount_in: new BigNumber(userBalanceTokenIn) + .multipliedBy(tokenInBalanceFraction) + .toFixed(0), + token_in: denomIn, + token_out: denomOut, + creator: address, + receiver: address, + order_type: orderTypeEnum.IMMEDIATE_OR_CANCEL, + // don't use a limit to find target amount in + tick_index_in_to_out: Long.fromNumber(priceMaxIndex), + }; + } } - return undefined; - }, [routerResult, tokenId0, token0Ticks, token1Ticks]); + }, [address, tokenIn, tokenInBalanceFraction, tokenOut, userBalanceTokenIn]); + const { + data: buyAmountSimulationResult, + isValidating: isValidatingBuyAmountSimulationResult, + } = useSimulatedLimitOrderResult(buyAmountSimulatedMsgPlaceLimitOrder); - const onFormSubmit = useCallback( - function (event?: React.FormEvent) { - if (event) event.preventDefault(); - // calculate tolerance from user slippage settings - // set tiny minimum of tolerance as the frontend calculations - // don't always exactly align with the backend calculations - const tolerance = Math.max(1e-12, Number(formState.slippage) || 0); - const tickIndexOut = routerResult?.tickIndexOut?.toNumber() || NaN; - const { execution, timePeriod } = formState; - const amount = Number(formState.amount ?? NaN); + const { + amountInDisplayAmount, + amountInBaseAmount, + amountOutDisplayAmount, + amountOutBaseAmount, + } = useMemo(() => { + if (tokenIn && tokenOut) { + // get amount in from input in sell mode or the slider in buy mode + const amountInBaseAmount = !buyMode + ? // get amount in from sell mode (in base amount to round input correctly) + getBaseDenomAmount(tokenIn, formState.amount || 0) + : // in buy mode get the input slider value only if defined + (tokenInBalanceFraction !== undefined && + new BigNumber(userBalanceTokenIn || 0) + .multipliedBy(tokenInBalanceFraction || 0) + .toFixed(0)) || + undefined; + // get amount out from buy mode + const amountOutBaseAmount = + (buyMode || undefined) && + (buyAmountSimulationResult || isValidatingBuyAmountSimulationResult + ? // if we have a buy simulation result then show it or loading state + buyAmountSimulationResult?.response?.taker_coin_out.amount ?? '' + : // else use value directly (in base amount to round input correctly) + getBaseDenomAmount(tokenOut, formState.amount || 0)); + + // return converted values for convenience + return { + amountInBaseAmount, + amountInDisplayAmount: !buyMode + ? formState.amount + : amountInBaseAmount && + getDisplayDenomAmount(tokenIn, amountInBaseAmount), + amountOutBaseAmount, + amountOutDisplayAmount: + buyAmountSimulationResult || isValidatingBuyAmountSimulationResult + ? amountOutBaseAmount && + getDisplayDenomAmount(tokenOut, amountOutBaseAmount, { + // output a little more rounded than usual for form inputs + fractionalDigits: 3, + significantDigits: 5, + }) + : formState.amount, + }; + } + return {}; + }, [ + buyAmountSimulationResult, + buyMode, + formState.amount, + isValidatingBuyAmountSimulationResult, + tokenInBalanceFraction, + tokenIn, + tokenOut, + userBalanceTokenIn, + ]); + + const simulatedMsgPlaceLimitOrder: MsgPlaceLimitOrder | undefined = + useMemo(() => { + const [denomIn, denomOut] = [getTokenId(tokenIn), getTokenId(tokenOut)]; + + const execution = formState.execution; + const timePeriod = formState.timePeriod; const timeAmount = Number(formState.timeAmount ?? NaN); - const limitPrice = Number(formState.limitPrice ?? NaN); - const triggerPrice = Number(formState.triggerPrice ?? NaN); + const limitPrice = Number(formState.limitPrice || NaN); // do not allow 0 // calculate the expiration time in JS epoch (milliseconds) const expirationTimeMs = timeAmount && timePeriod ? new Date(Date.now() + timeAmount * timeUnits[timePeriod]).getTime() : NaN; + + // find amounts in/out for the order + // in buy mode: buy the amount out with the user's available balance + const amountIn = + amountInBaseAmount || + // use bank balance in buy mode if amount was not defined by slider + (buyMode ? userBalanceTokenIn : undefined); + // use amount out to set the order limit only if the amount in is not set + const maxAmountOut = amountOutBaseAmount; + + // check format of request if ( - !isNaN(amount) && execution && (execution === 'GOOD_TIL_TIME' ? !isNaN(expirationTimeMs) : true) && (execution === 'GOOD_TIL_TIME' ? timePeriod !== undefined : true) && - (showLimitPrice ? !isNaN(limitPrice) : true) && - (showTriggerPrice ? !isNaN(triggerPrice) : true) && address && - routerResult && - tokenIn && - tokenOut && - !isNaN(tolerance) && - !isNaN(tickIndexOut) + denomIn && + denomOut && + amountIn && + userBalanceTokenIn && + Number(amountIn) > 0 && + // either amount in or out should be more than zero + (Number(amountInBaseAmount) > 0 || Number(amountOutBaseAmount) > 0) ) { - // convert to swap request format - const result = routerResult; - const forward = result.tokenIn === tokenId0; - const tickIndexLimit = tickIndexOut * (forward ? 1 : -1); - swapRequest( - { - amount_in: getBaseDenomAmount(tokenIn, result.amountIn) || '0', - token_in: result.tokenIn, - token_out: result.tokenOut, - creator: address, - receiver: address, - // see LimitOrderType in types repo (cannot import at runtime) - // https://github.com/duality-labs/neutronjs/blob/2cf50a7af7bf7c6b1490a590a4e1756b848096dd/src/codegen/duality/dex/tx.ts#L6-L13 - // using type IMMEDIATE_OR_CANCEL so that partially filled requests - // succeed (in testing when swapping 1e18 utokens, often the order - // would be filled with 1e18-2 utokens and FILL_OR_KILL would fail) - // todo: use type FILL_OR_KILL: order must be filled completely - order_type: orderTypeEnum[execution], - // todo: set tickIndex to allow for a tolerance: - // the below function is a tolerance of 0 - tick_index_in_to_out: Long.fromNumber( - showLimitPrice - ? // set given limit price - displayPriceToTickIndex( - new BigNumber(limitPrice), - forward ? tokenIn : tokenOut, - forward ? tokenOut : tokenIn - )?.toNumber() || NaN - : // or default to market end trade price (with tolerance) - tickIndexLimit - ), - // optional params - // only add maxOut for "taker" (immediate) orders - ...((execution === 'FILL_OR_KILL' || - execution === 'IMMEDIATE_OR_CANCEL') && { - maxAmountOut: - getBaseDenomAmount(tokenOut, result.amountOut) || '0', - }), - // only add expiration time to timed limit orders - ...(execution === 'GOOD_TIL_TIME' && - !isNaN(expirationTimeMs) && { - expirationTime: { - seconds: Long.fromNumber(Math.round(expirationTimeMs / 1000)), - nanos: 0, - }, - }), - }, - gasEstimate || 0 - ); + // when buying: select tick index below the limit + // when selling: select tick index above the limit + const rounding = buyMode ? 'floor' : 'ceil'; + const limitTickIndexInToOut = + limitPrice > 0 + ? displayPriceToTickIndex( + new BigNumber(limitPrice), + tokenOut, + tokenIn, + rounding + ) + // change limit direction depending on token direction + ?.multipliedBy(buyMode ? -1 : 1) + : undefined; + + const msgPlaceLimitOrder: MsgPlaceLimitOrder = { + amount_in: BigNumber.min(amountIn, userBalanceTokenIn).toFixed(0), + token_in: denomIn, + token_out: denomOut, + creator: address, + receiver: address, + order_type: orderTypeEnum[execution] as LimitOrderType, + // if no limit assume market value + tick_index_in_to_out: + limitTickIndexInToOut !== undefined + ? Long.fromNumber(limitTickIndexInToOut.toNumber()) + : Long.fromNumber(priceMaxIndex), + }; + // optional params + // only add maxOut for "taker" (immediate) orders + if ( + tokenOut && + maxAmountOut && + (execution === 'FILL_OR_KILL' || execution === 'IMMEDIATE_OR_CANCEL') + ) { + msgPlaceLimitOrder.max_amount_out = maxAmountOut; + } + // only add expiration time to timed limit orders + if (execution === 'GOOD_TIL_TIME' && !isNaN(expirationTimeMs)) { + msgPlaceLimitOrder.expiration_time = { + seconds: Long.fromNumber(Math.round(expirationTimeMs / 1000)), + nanos: 0, + }; + } + return msgPlaceLimitOrder; + } + }, [ + address, + amountInBaseAmount, + amountOutBaseAmount, + buyMode, + formState.execution, + formState.limitPrice, + formState.timeAmount, + formState.timePeriod, + tokenIn, + tokenOut, + userBalanceTokenIn, + ]); + + const { data: simulationResult, isValidating: isValidatingSimulation } = + useSimulatedLimitOrderResult(simulatedMsgPlaceLimitOrder); + + const onFormSubmit = useCallback( + function (event?: React.FormEvent) { + if (event) event.preventDefault(); + + // calculate last price out from result + const lastPriceEvent = simulationResult?.result?.events.findLast( + (event) => event.type === 'TickUpdate' + ); + + if (lastPriceEvent) { + const denomIn = getTokenId(tokenIn); + + // calculate tolerance from user slippage settings + // set tiny minimum of tolerance as the frontend calculations + // don't always exactly align with the backend calculations + const tolerance = Math.max(1e-12, Number(formState.slippage) || 0); + const toleranceFactor = 1 + tolerance; + // calculate last price out from matching event results + const lastPrice = + mapEventAttributes(lastPriceEvent)?.attributes; + const direction = + lastPrice.TokenIn === denomIn + ? lastPrice.TokenIn === lastPrice.TokenZero + : lastPrice.TokenIn === lastPrice.TokenOne; + + const tickIndexLimitInToOut = direction + ? Math.floor(Number(lastPrice.TickIndex) / toleranceFactor) + : Math.floor(Number(lastPrice.TickIndex) * toleranceFactor); + + if (simulatedMsgPlaceLimitOrder && tickIndexLimitInToOut) { + const msgPlaceLimitOrder = { + ...simulatedMsgPlaceLimitOrder, + tick_index_in_to_out: Long.fromNumber(tickIndexLimitInToOut), + }; + const gasEstimate = simulationResult?.gasInfo?.gasUsed.toNumber(); + swapRequest(msgPlaceLimitOrder, gasEstimate || 0); + } } }, [ formState, - routerResult, - showLimitPrice, - showTriggerPrice, - address, tokenIn, - tokenOut, - tokenId0, - gasEstimate, + simulatedMsgPlaceLimitOrder, + simulationResult, swapRequest, ] ); const warning = useMemo(() => { - const { amount, limitPrice } = formState; - if (Number(amount) > 0) { - if (showLimitPrice && !Number(limitPrice)) { - return 'Limit Price is not valid'; + // check simulation-less conditions first + // check if sell input amount is too high + if ( + !buyMode && + tokenIn && + amountInBaseAmount && + userBalanceTokenIn && + userBalanceTokenInDisplayAmount && + new BigNumber(amountInBaseAmount).isGreaterThan(userBalanceTokenIn) + ) { + return `Order limited to input balance: ${formatAmount( + userBalanceTokenInDisplayAmount + )}${tokenIn?.symbol}`; + } + // else check simulation results + else if (simulatedMsgPlaceLimitOrder && simulationResult?.response) { + // set tolerance for imprecise checks + const tolerance = 0.0001; + + // check direct sell type (car be in buy mode using balance range slider) + const sellOrderIsLimited = + amountInBaseAmount !== undefined && + userBalanceTokenIn !== undefined && + new BigNumber(amountInBaseAmount).isGreaterThan(userBalanceTokenIn); + + // note: this warning can be triggered by amount in ~= the user's balance + const buyOrderIsLimited = + amountInBaseAmount === undefined && + userBalanceTokenIn !== undefined && + simulationResult && + new BigNumber(simulationResult.response.coin_in.amount) + // make up for possible rounding on Dex (note this is inaccurate) + .multipliedBy(1 + tolerance) + .isGreaterThan(userBalanceTokenIn); + + // check if the trade has been limited + if (buyOrderIsLimited || sellOrderIsLimited) { + return `Order limited to input balance: ${formatAmount( + userBalanceTokenInDisplayAmount || '?' + )}${tokenIn?.symbol}`; + } + + // check for insufficient liquidity + if ( + // check if less was used than expected + (amountInBaseAmount !== undefined && + new BigNumber(simulationResult.response.coin_in.amount) + // make up for possible rounding on Dex (note this is inaccurate) + .multipliedBy(1 + tolerance) + .isLessThan(amountInBaseAmount)) || + // check if less was found than expected + (amountOutBaseAmount !== undefined && + new BigNumber(simulationResult.response.taker_coin_out.amount) + // make up for possible rounding on Dex (note this is inaccurate) + .multipliedBy(1 + tolerance) + .isLessThan(amountOutBaseAmount)) + ) { + return `Insufficient liquidity: max ${formatAmount( + simulationResult.response.coin_in.amount, + { + useGrouping: true, + } + )}${tokenIn?.symbol} used`; } } return undefined; - }, [formState, showLimitPrice]); + }, [ + amountInBaseAmount, + amountOutBaseAmount, + buyMode, + simulatedMsgPlaceLimitOrder, + simulationResult, + tokenIn, + userBalanceTokenIn, + userBalanceTokenInDisplayAmount, + ]); - const [chainFeeToken] = useChainFeeToken(); + // set fee token from native chain if not yet set + const [chainFeeToken, setChainFeeToken] = useChainFeeToken(); + const { data: nativeChain } = useNativeChain(); + useEffect(() => { + const firstFeeToken = nativeChain?.fees?.fee_tokens.at(0); + if (firstFeeToken) { + setChainFeeToken((feeToken) => feeToken || firstFeeToken.denom); + } + }, [nativeChain, setChainFeeToken]); - return ( -
+ const [lastKnownPrice, setLastKnownPrice] = useState(0); + useEffect(() => { + if (simulationResult?.response) { + const price = new BigNumber(simulationResult.response.coin_in.amount).div( + simulationResult.response.taker_coin_out.amount + ); + setLastKnownPrice(price.toNumber()); + } + }, [simulationResult?.response]); + + const currentPriceFromTicks = useCurrentPriceFromTicks(denomIn, denomOut); + + // disable fieldset with no address because the estimation requires a signed client + const fieldset = ( +
{ + formSetState.setAmount?.(value); + setTokenInBalanceFraction(undefined); + }} suffix={tokenA?.symbol} - format={formatAmount} - // todo: estimate amountIn needed to match an amountOut value - // to be able to allow setting amountOut here in buyMode - readOnly={buyMode} + format={formatNumericAmount('')} />
{ - const numericValue = Number(value); - const newValue = - numericValue < 1 - ? // round calculated values - formatAmount( - new BigNumber(userTokenInDisplayAmount || 0) - .multipliedBy(numericValue) - .toNumber() - ) - : // or pass full value (while truncating fractional zeros) - new BigNumber(userTokenInDisplayAmount || '0').toFixed(); - if (newValue) { - formSetState.setAmount?.(newValue || ''); + const numericValue = Math.max(0, Math.min(1, Number(value) || 0)); + if (buyMode) { + formSetState.setAmount?.(''); + setTokenInBalanceFraction(numericValue); + } else { + setTokenInBalanceFraction(undefined); + const display = new BigNumber( + userBalanceTokenInDisplayAmount || 0 + ); + const newValue = + numericValue < 1 + ? // round calculated values + formatAmount(display.multipliedBy(numericValue).toNumber()) + : // or pass full value (while truncating fractional zeros) + display.toFixed(); + if (newValue) { + formSetState.setAmount?.(newValue || ''); + } } }, - [formSetState, userTokenInDisplayAmount] + [buyMode, formSetState, userBalanceTokenInDisplayAmount] )} /> {showLimitPrice && (
='}`} value={formState.limitPrice ?? ''} + placeholder="market" onChange={formSetState.setLimitPrice} suffix={tokenA && tokenB && `${tokenA.symbol}/${tokenB.symbol}`} - format={formatAmount} - /> -
- )} - {showTriggerPrice && ( -
-
)}
className="flex col m-0 p-0" - list={Object.keys(orderTypeTextMap) as AllowedLimitOrderTypeKey[]} + list={ + Object.keys(inputOrderTypeTextMap) as Array< + keyof typeof inputOrderTypeTextMap + > + } getLabel={(key = defaultExecutionType) => - key && orderTypeTextMap[key] + key && inputOrderTypeTextMap[key] } value={formState.execution} onChange={formSetState.setExecution} @@ -452,14 +649,17 @@ function LimitOrder({ @@ -469,14 +669,15 @@ function LimitOrder({ prefix="Est. Average Price" value={formatPrice( formatMaximumSignificantDecimals( - buyMode - ? routerResult?.amountOut - .div(routerResult.amountIn) - .toNumber() || '-' - : routerResult?.amountIn - .div(routerResult.amountOut) - .toNumber() || '-', - 3 + simulationResult?.response + ? !buyMode + ? new BigNumber( + simulationResult.response.taker_coin_out.amount + ).div(simulationResult.response.coin_in.amount) + : new BigNumber(simulationResult.response.coin_in.amount).div( + simulationResult.response.taker_coin_out.amount + ) + : '-' ) )} suffix={tokenA && tokenB && `${tokenA.symbol}/${tokenB.symbol}`} @@ -484,12 +685,19 @@ function LimitOrder({
@@ -507,56 +715,43 @@ function LimitOrder({
- {!buyMode ? ( - - ) : ( - - )} - + + ); + return
{fieldset}
; } function NumericInputRow({ className, prefix = '', value = '', + placeholder = '0', onInput, onChange = onInput, suffix = '', @@ -568,6 +763,7 @@ function NumericInputRow({ className?: string; prefix?: string; value: string; + placeholder?: string; onInput?: (value: string) => void; onChange?: (value: string) => void; suffix?: string; @@ -613,12 +809,12 @@ function NumericInputRow({ maybeUpdate(inputRef.current?.value || '0', onInput)} + onInput={() => maybeUpdate(inputRef.current?.value || '', onInput)} onChange={(e) => { setInternalValue(e.target.value); - maybeUpdate(e.target.value || '0', onChange); + maybeUpdate(e.target.value || '', onChange); }} onBlur={() => setInternalValue(undefined)} readOnly={readOnly} diff --git a/src/components/cards/LimitOrderContext.tsx b/src/components/cards/LimitOrderContext.tsx index 90fb5aa2b..79759de3d 100644 --- a/src/components/cards/LimitOrderContext.tsx +++ b/src/components/cards/LimitOrderContext.tsx @@ -14,7 +14,6 @@ import { interface FormState { amount: string; limitPrice: string; - triggerPrice: string; timeAmount: string; timePeriod: TimePeriod; execution: AllowedLimitOrderTypeKey; @@ -23,7 +22,6 @@ interface FormState { interface FormSetState { setAmount: Dispatch>; setLimitPrice: Dispatch>; - setTriggerPrice: Dispatch>; setTimeAmount: Dispatch>; setTimePeriod: Dispatch>; setExecution: Dispatch>; @@ -44,7 +42,6 @@ export function LimitOrderContextProvider({ }) { const [amount, setAmount] = useState(''); const [limitPrice, setLimitPrice] = useState(''); - const [triggerPrice, setTriggerPrice] = useState(''); const [timeAmount, setTimeAmount] = useState('28'); const [timePeriod, setTimePeriod] = useState('days'); const [execution, setExecution] = useState(defaultExecutionType); @@ -54,27 +51,17 @@ export function LimitOrderContextProvider({ return { amount, limitPrice, - triggerPrice, timeAmount, timePeriod, execution, slippage, }; - }, [ - amount, - limitPrice, - triggerPrice, - timeAmount, - timePeriod, - execution, - slippage, - ]); + }, [amount, limitPrice, timeAmount, timePeriod, execution, slippage]); const setState = useMemo(() => { return { setAmount, setLimitPrice, - setTriggerPrice, setTimeAmount, setTimePeriod, setExecution, @@ -83,7 +70,6 @@ export function LimitOrderContextProvider({ }, [ setAmount, setLimitPrice, - setTriggerPrice, setTimeAmount, setTimePeriod, setExecution, diff --git a/src/lib/web3/utils/limitOrders.ts b/src/lib/web3/utils/limitOrders.ts index 26f00025a..9398868e3 100644 --- a/src/lib/web3/utils/limitOrders.ts +++ b/src/lib/web3/utils/limitOrders.ts @@ -23,6 +23,13 @@ export const orderTypeEnum: { UNRECOGNIZED: -1, }; +export const inputOrderTypeTextMap: Partial<{ + [key in AllowedLimitOrderTypeKey]: string; +}> = { + FILL_OR_KILL: 'Fill Or Kill', + IMMEDIATE_OR_CANCEL: 'Immediate Or Cancel', +}; + export const orderTypeTextMap: { [key in keyof typeof LimitOrderType]: string; } = { diff --git a/src/pages/App/App.tsx b/src/pages/App/App.tsx index 186fc57fc..7a79ea199 100644 --- a/src/pages/App/App.tsx +++ b/src/pages/App/App.tsx @@ -22,8 +22,6 @@ import MyLiquidity from '../MyLiquidity'; import './App.scss'; -const { REACT_APP__HIDE_ORDERBOOK = '' } = import.meta.env; - const queryClient = new QueryClient(); function App() { @@ -40,9 +38,7 @@ function App() { } /> } /> } /> - {!REACT_APP__HIDE_ORDERBOOK && ( - } /> - )} + } /> } /> } /> Not found} /> diff --git a/src/pages/Swap/Swap.tsx b/src/pages/Swap/Swap.tsx index 59dcd0f73..2046f1cf5 100644 --- a/src/pages/Swap/Swap.tsx +++ b/src/pages/Swap/Swap.tsx @@ -151,7 +151,7 @@ function Swap() { isValidating: isValidatingRate, error: simulationError = simulationResult?.error, refetch: simulationRefetch, - } = useSimulatedLimitOrderResult(swapMsg); + } = useSimulatedLimitOrderResult(swapMsg, { keepPreviousData: true }); const rate = simulationResult?.response && diff --git a/src/pages/Swap/hooks/index.d.ts b/src/pages/Swap/hooks/index.d.ts deleted file mode 100644 index f874e3c6a..000000000 --- a/src/pages/Swap/hooks/index.d.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { BigNumber } from 'bignumber.js'; - -export interface PairRequest { - /** ID of token A */ - tokenA?: string; - /** ID of token B */ - tokenB?: string; - /** value of token A (falsy if B was just altered) */ - valueA?: string; - /** value of token B (falsy if A was just altered) */ - valueB?: string; -} - -export interface PairResult { - /** ID of token A */ - tokenA: string; - /** ID of token B */ - tokenB: string; - /** value for token A */ - valueA: string; - /** (estimated) value for token B */ - valueB: string; - /** (estimated) rate of exchange */ - rate: string; - /** (estimated) gas fee */ - gas: string; -} - -/** - * RouterResult is a reflection of the backend structue "MsgSwap" - * but utilising BigNumber type instead of BigNumberString type properties - */ -export interface RouterResult { - tokenIn: string; // token ID - tokenOut: string; // token ID - amountIn: BigNumber; - amountOut: BigNumber; - priceBToAIn: BigNumber | undefined; - priceBToAOut: BigNumber | undefined; - tickIndexIn: BigNumber | undefined; - tickIndexOut: BigNumber | undefined; -} diff --git a/src/pages/Swap/hooks/router.ts b/src/pages/Swap/hooks/router.ts deleted file mode 100644 index 08c582c91..000000000 --- a/src/pages/Swap/hooks/router.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { TickInfo } from '../../../lib/web3/utils/ticks'; -import { RouterResult } from './index'; -import { BigNumber } from 'bignumber.js'; - -export type SwapError = Error & { - insufficientLiquidity?: boolean; - insufficientLiquidityIn?: boolean; - insufficientLiquidityOut?: boolean; - result?: RouterResult; -}; - -// mock implementation of router (no hop) -export function router( - valueA: string, - tokenA: string, - tokenB: string, - token0: string, - token0Ticks: TickInfo[] = [], - token1Ticks: TickInfo[] = [] -): RouterResult { - let error: SwapError | false = false; - - // find pair by searching both directions in the current state - const forward = token0 === tokenA; - const reverse = !forward; - - if (!(token0Ticks.length + token1Ticks.length > 0)) { - error = new Error('There are no ticks for the supplied token pair'); - error.insufficientLiquidity = true; - throw error; - } else { - // todo: the calculateOut can be done more efficiently using separated tick lists - const sortedTicks = token0Ticks - .concat(token1Ticks) - .filter((tick) => !tick.reserve0.isZero() || !tick.reserve1.isZero()) - .sort( - forward - ? (a, b) => Number(a.tickIndex1To0) - Number(b.tickIndex1To0) - : (a, b) => Number(b.tickIndex1To0) - Number(a.tickIndex1To0) - ); - const amountIn = new BigNumber(valueA); - - try { - const { - amountOut, - priceBToAIn, - priceBToAOut, - tickIndexIn, - tickIndexOut, - } = calculateOut({ - tokenIn: tokenA, - tokenOut: tokenB, - amountIn: amountIn, - sortedTicks, - }); - const ticksOut = reverse ? token0Ticks : token1Ticks; - const maxOut = ticksOut.reduce((result, tick) => { - return result.plus(reverse ? tick.reserve0 : tick.reserve1); - }, new BigNumber(0)); - - if (amountOut.isGreaterThan(maxOut)) { - if (!error) { - error = new Error('Not enough tick liquidity found to match trade'); - } - error.insufficientLiquidity = true; - error.insufficientLiquidityOut = true; - } - - if (error) { - throw error; - } - - return { - amountIn, - tokenIn: tokenA, - tokenOut: tokenB, - amountOut, - priceBToAIn, - priceBToAOut, - tickIndexIn, - tickIndexOut, - }; - } catch (err) { - // eslint-disable-next-line no-console - console.error('calculation error', err); - throw err; - } - } -} - -export async function routerAsync( - valueA: string, - tokenA: string, - tokenB: string, - token0: string, - token0Ticks: TickInfo[] = [], - token1Ticks: TickInfo[] = [] -): Promise { - return router(valueA, tokenA, tokenB, token0, token0Ticks, token1Ticks); -} - -/** - * Calculates the amountOut using the (amountIn * price0) / (price1) formula - * for each tick, until the amountIn amount has been covered - * @param data the RouteInput struct - * @returns estimated value for amountOut - */ -export function calculateOut({ - tokenIn, - tokenOut, - amountIn, - sortedTicks, -}: { - tokenIn: string; // token ID - tokenOut: string; // token ID - amountIn: BigNumber; // amount in (in minimum denom) - sortedTicks: Array; -}): { - amountOut: BigNumber; - priceBToAIn: BigNumber | undefined; - priceBToAOut: BigNumber | undefined; - tickIndexIn: BigNumber | undefined; - tickIndexOut: BigNumber | undefined; -} { - // amountLeft is the amount of tokenIn left to be swapped - let amountLeft = amountIn; - // amountOut is the amount of tokenOut accumulated by the swap - let amountOut = new BigNumber(0); - // priceOut will be the first liquidity price touched by the swap - let priceIn: BigNumber | undefined; - // priceOut will be the last liquidity price touched by the swap - let priceOut: BigNumber | undefined; - // tickIndexIn will be the first liquidity index touched by the swap - let tickIndexIn: BigNumber | undefined; - // tickIndexOut will be the last liquidity index touched by the swap - let tickIndexOut: BigNumber | undefined; - // tokenPath is the route used to swap as an array - // eg. tokenIn -> something -> something else -> tokenOut - // as: [tokenIn, something, somethingElse, tokenOut] - // TODO: handle more than the 1 hop path - const tokenPath = [tokenIn, tokenOut]; - // loop through token path pairs - for (let pairIndex = 0; pairIndex < tokenPath.length - 1; pairIndex++) { - const tokens = [tokenPath[pairIndex], tokenPath[pairIndex + 1]].sort(); - // loop through the ticks of the current token pair - for (let tickIndex = 0; tickIndex < sortedTicks.length; tickIndex++) { - // find price in the right direction - const isSameOrder = tokens[0] === tokenPath[pairIndex]; - const priceBToA = isSameOrder - ? sortedTicks[tickIndex].price1To0 - : new BigNumber(1).dividedBy(sortedTicks[tickIndex].price1To0); - // the reserves of tokenOut available at this tick - const reservesOut = isSameOrder - ? sortedTicks[tickIndex].reserve1 - : sortedTicks[tickIndex].reserve0; - if (reservesOut.isGreaterThan(0)) { - priceIn = priceIn || priceBToA; - priceOut = priceBToA; - tickIndexIn = tickIndexIn || sortedTicks[tickIndex].tickIndex1To0; - tickIndexOut = sortedTicks[tickIndex].tickIndex1To0; - } - // the reserves of tokenOut available at this tick - const maxOut = amountLeft - .dividedBy(priceBToA) - .decimalPlaces(0, BigNumber.ROUND_DOWN); - - // if there is enough liquidity in this tick, then exit with this amount - if (reservesOut.isGreaterThanOrEqualTo(maxOut)) { - amountOut = amountOut.plus(maxOut); - amountLeft = new BigNumber(0); - } - // if not add what is available - else { - amountOut = amountOut.plus(reservesOut); - // calculate how much amountIn is still needed to be satisfied - const amountInTraded = reservesOut - .multipliedBy(priceBToA) - .decimalPlaces(0, BigNumber.ROUND_UP); - amountLeft = amountLeft.minus(amountInTraded); - } - // if amount in has all been swapped, the exit successfully - if (amountLeft.isZero()) { - return getLastState(); - } - // if somehow the amount left to take out is over-satisfied the error - else if (amountLeft.isLessThan(0)) { - const error: SwapError = new Error( - 'Error while calculating amount out (negative amount)' - ); - error.insufficientLiquidity = true; - error.insufficientLiquidityIn = true; - error.result = getLastState(); - throw error; - } - // if amountLeft is greater that zero then proceed to next tick - } - } - // if there is still tokens left to be traded the liquidity must have been exhausted - if (amountLeft.isGreaterThan(0)) { - const error: SwapError = new Error('Could not swap all tokens given'); - error.insufficientLiquidity = true; - error.insufficientLiquidityOut = true; - error.result = getLastState(); - throw error; - } - // somehow we have looped through all ticks and exactly satisfied the needed swap - // yet did not match the positive exiting condition - // this can happen if the amountIn is zero and there are no ticks in the pair - return getLastState(); - - function getLastState(): RouterResult { - return { - amountIn, - tokenIn, - tokenOut, - amountOut, - priceBToAIn: priceIn, - priceBToAOut: priceOut, - tickIndexIn, - tickIndexOut, - }; - } -} - -// mock implementation of fee calculation -export function calculateFee(): BigNumber { - return new BigNumber(0); -} diff --git a/src/pages/Swap/hooks/useRouter.ts b/src/pages/Swap/hooks/useRouter.ts index 3251e9df4..b3e2d6487 100644 --- a/src/pages/Swap/hooks/useRouter.ts +++ b/src/pages/Swap/hooks/useRouter.ts @@ -1,5 +1,4 @@ import BigNumber from 'bignumber.js'; -import { useEffect, useState } from 'react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { TxExtension } from '@cosmjs/stargate'; import { neutron } from '@duality-labs/neutronjs'; @@ -8,56 +7,14 @@ import type { MsgPlaceLimitOrderResponse, } from '@duality-labs/neutronjs/types/codegen/neutron/dex/tx'; -import { PairRequest, PairResult, RouterResult } from './index'; -import { routerAsync, calculateFee, SwapError } from './router'; - -import { formatMaximumSignificantDecimals } from '../../../lib/utils/number'; -import { TickInfo } from '../../../lib/web3/utils/ticks'; -import { getPairID } from '../../../lib/web3/utils/pairs'; -import { - getBaseDenomAmount, - getDisplayDenomAmount, -} from '../../../lib/web3/utils/tokens'; - import { useWeb3 } from '../../../lib/web3/useWeb3'; import { useTxSimulationClient, TxSimulationError, } from '../../../lib/web3/clients/signingClients'; -import { useToken } from '../../../lib/web3/hooks/useDenomClients'; -import { useTokenPairTickLiquidity } from '../../../lib/web3/hooks/useTickLiquidity'; -import { useOrderedTokenPair } from '../../../lib/web3/hooks/useTokenPairs'; import { useSwrResponseFromReactQuery } from '../../../lib/web3/hooks/useSWR'; -const cachedRequests: { - [token0: string]: { [token1: string]: PairResult }; -} = {}; - -async function getRouterResult( - alteredValue: string, - tokenA: string, - tokenB: string, - token0: string, - token0Ticks: TickInfo[] = [], - token1Ticks: TickInfo[] = [], - reverseSwap: boolean -): Promise { - if (reverseSwap) { - // The router can't calculate the value of the buying token based on the value of the selling token (yet) - throw new Error('Cannot calculate the reverse value'); - } else { - return await routerAsync( - alteredValue, - tokenA, - tokenB, - token0, - token0Ticks, - token1Ticks - ); - } -} - type SimulateResponse = Awaited>; type ExtendedSimulateResponse = Partial< SimulateResponse & { @@ -88,7 +45,7 @@ class LimitOrderTxSimulationError extends TxSimulationError { */ export function useSimulatedLimitOrderResult( msgPlaceLimitOrder: MsgPlaceLimitOrder | undefined, - memo = '' + opts: Partial<{ memo: string; keepPreviousData: boolean }> = {} ) { // use signing client simulation function to get simulated response and gas const { wallet, address } = useWeb3(); @@ -112,7 +69,7 @@ export function useSimulatedLimitOrderResult( msgPlaceLimitOrder ), ], - memo + opts.memo ); // return successful response if (result && result.msgResponses.length > 0) { @@ -135,7 +92,7 @@ export function useSimulatedLimitOrderResult( } }, // persist results (with error in error key) - placeholderData: keepPreviousData, + placeholderData: opts.keepPreviousData ? keepPreviousData : undefined, }); return useSwrResponseFromReactQuery< @@ -143,210 +100,3 @@ export function useSimulatedLimitOrderResult( LimitOrderTxSimulationError >(result.data, result); } - -/** - * Gets the estimated info of a swap transaction - * @param pairRequest the respective IDs and value - * @returns estimated info of swap, loading state and possible error - */ -export function useRouterResult(pairRequest: PairRequest): { - data?: RouterResult; - isValidating: boolean; - error?: SwapError; -} { - const [data, setData] = useState(); - const [isValidating, setIsValidating] = useState(false); - const [error, setError] = useState(); - - const [token0, token1] = - useOrderedTokenPair([pairRequest.tokenA, pairRequest.tokenB]) || []; - - const pairId = token0 && token1 ? getPairID(token0, token1) : null; - const { - data: [token0Ticks, token1Ticks], - } = useTokenPairTickLiquidity([token0, token1]); - - const { data: tokenA } = useToken(pairRequest.tokenA); - const { data: tokenB } = useToken(pairRequest.tokenB); - - useEffect(() => { - if ( - !pairRequest.tokenA || - !pairRequest.tokenB || - (!pairRequest.valueA && !pairRequest.valueB) || - !token0 || - !token1 || - !pairId - ) { - return; - } - if (pairRequest.tokenA === pairRequest.tokenB) { - setData(undefined); - setError(new Error('The tokens cannot be the same')); - return; - } - if (pairRequest.valueA && pairRequest.valueB) { - setData(undefined); - setError(new Error('One value must be falsy')); - return; - } - if (!tokenA || !tokenB) { - setData(undefined); - setError(new Error('Tokens not found: token list not ready')); - return; - } - setIsValidating(true); - setData(undefined); - setError(undefined); - // convert token request down into base denom - const alteredValue = getBaseDenomAmount(tokenA, pairRequest.valueA || 0); - const reverseSwap = !!pairRequest.valueB; - if (!alteredValue || alteredValue === '0') { - setIsValidating(false); - setData(undefined); - return; - } - let cancelled = false; - - // this could be useRouterResult for much better usage - // replacing the above useEffect with probably a useMemo - getRouterResult( - alteredValue, - pairRequest.tokenA, - pairRequest.tokenB, - token0, - token0Ticks, - token1Ticks, - reverseSwap - ) - .then(function (result) { - if (cancelled) return; - setIsValidating(false); - // convert token result back into display denom - setData(convertToDisplayDenom(result)); - }) - .catch(function (err: SwapError) { - if (cancelled) return; - setIsValidating(false); - setError(err); - if (err.result) { - setData(convertToDisplayDenom(err.result)); - } else { - setData(undefined); - } - }); - - return () => { - cancelled = true; - }; - - // todo: this function should deal with uToken values only and not be concerned with conversions - function convertToDisplayDenom(result: RouterResult): RouterResult { - return { - ...result, - amountIn: new BigNumber(pairRequest.valueA || 0), - amountOut: new BigNumber( - (tokenB && - getDisplayDenomAmount( - tokenB, - result.amountOut.decimalPlaces(0, BigNumber.ROUND_DOWN) - )) || - 0 - ), - }; - } - }, [ - pairRequest.tokenA, - pairRequest.tokenB, - pairRequest.valueA, - pairRequest.valueB, - pairId, - token0, - token1, - token0Ticks, - token1Ticks, - tokenA, - tokenB, - ]); - - return { data, isValidating, error }; -} - -/** - * Gets the estimated info of a swap transaction - * @param pairRequest the respective IDs and value - * @param routerResult the results of the router (if they exist) - * @returns estimated info of swap - */ -export function getRouterEstimates( - pairRequest: PairRequest, - routerResult: RouterResult | undefined -): PairResult | undefined { - // note: this sorting on the frontend may differ from the sorting on the backend - const [token0, token1] = [pairRequest.tokenA, pairRequest.tokenB].sort(); - if (token0 && token1) { - // return estimate from current result - if (routerResult) { - const rate = routerResult.amountOut.dividedBy(routerResult.amountIn); - const extraFee = calculateFee(); - // todo: use result - // const extraFee = calculateFee(routerResult); - const estimate = { - tokenA: routerResult.tokenIn, - tokenB: routerResult.tokenOut, - rate: rate.toFixed(), - valueA: formatMaximumSignificantDecimals(routerResult.amountIn), - valueB: formatMaximumSignificantDecimals(routerResult.amountOut), - gas: extraFee.toFixed(), - }; - cachedRequests[token0] = cachedRequests[token0] || {}; - cachedRequests[token0][token1] = estimate; - return estimate; - } - // if current result is not available, return cached value rough estimate - else { - cachedRequests[token0] = cachedRequests[token0] || {}; - const cachedPairInfo = cachedRequests[token0][token1]; - - const alteredValue = pairRequest.valueA ?? pairRequest.valueB; - const reverseSwap = !!pairRequest.valueB; - if ( - cachedPairInfo && - pairRequest.tokenA && - pairRequest.tokenB && - alteredValue - ) { - const { rate, gas } = cachedPairInfo; - const convertedRate = - pairRequest.tokenA === cachedPairInfo.tokenA - ? new BigNumber(rate) - : new BigNumber(1).dividedBy(rate); - const roughEstimate = formatMaximumSignificantDecimals( - new BigNumber(alteredValue).multipliedBy(convertedRate).toFixed() - ); - return { - tokenA: pairRequest.tokenA, - tokenB: pairRequest.tokenB, - rate: convertedRate.toFixed(), - valueA: reverseSwap ? roughEstimate : alteredValue, - valueB: reverseSwap ? alteredValue : roughEstimate, - gas, - }; - } - } - } -} - -/** - * Gets the estimated info of a swap transaction - * @param pairRequest the respective IDs and value - * @returns estimated info of swap, loading state and possible error - */ -export function useRouterEstimates(pairRequest: PairRequest): { - data?: PairResult; - isValidating: boolean; - error?: SwapError; -} { - const { data, error, isValidating } = useRouterResult(pairRequest); - return { data: getRouterEstimates(pairRequest, data), isValidating, error }; -} From 42ad114508d3bc56574c5e5f3e7006fcad753c9d Mon Sep 17 00:00:00 2001 From: David Uhlmann Date: Wed, 31 Jan 2024 20:01:41 +1000 Subject: [PATCH 03/35] feat: add Order Type help: link to docs --- src/components/cards/LimitOrderCard.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/components/cards/LimitOrderCard.tsx b/src/components/cards/LimitOrderCard.tsx index fc136e75b..af8813415 100644 --- a/src/components/cards/LimitOrderCard.tsx +++ b/src/components/cards/LimitOrderCard.tsx @@ -612,6 +612,17 @@ function LimitOrder({ )}
+
+ Order type{' '} + + ? + +
className="flex col m-0 p-0" list={ From c5eddc3a06fc0fd0f69385a1c89dd653502e3185 Mon Sep 17 00:00:00 2001 From: David Uhlmann Date: Tue, 12 Mar 2024 05:26:08 +1000 Subject: [PATCH 04/35] feat: switch pool token order when displaying a pair: tokenB/tokenA --- .../TokenPairLogos/TokenPairLogos.tsx | 8 +-- src/components/cards/LimitOrderCard.tsx | 12 ++--- src/pages/Orderbook/Orderbook.tsx | 4 +- src/pages/Orderbook/OrderbookChart.tsx | 4 +- src/pages/Orderbook/OrderbookHeader.tsx | 20 ++++---- src/pages/Orderbook/OrderbookList.tsx | 6 +-- src/pages/Pool/PoolLayout.tsx | 14 ++++-- src/pages/Pool/PoolManagement.tsx | 50 +++++++++---------- src/pages/Pool/PoolOverview.tsx | 14 +++--- src/pages/Pool/Pools.tsx | 4 +- 10 files changed, 70 insertions(+), 66 deletions(-) diff --git a/src/components/TokenPairLogos/TokenPairLogos.tsx b/src/components/TokenPairLogos/TokenPairLogos.tsx index 6060dac91..fffb3c347 100644 --- a/src/components/TokenPairLogos/TokenPairLogos.tsx +++ b/src/components/TokenPairLogos/TokenPairLogos.tsx @@ -42,12 +42,12 @@ function TokenLogo({ const tokenSwitchDelayMs = 800; export default function TokenPairLogos({ className, - tokenA, - tokenB, + tokenLeft: tokenA, + tokenRight: tokenB, }: { className?: string; - tokenA?: Token; - tokenB?: Token; + tokenLeft?: Token; + tokenRight?: Token; }) { const timeoutRef = useRef(null); const [previousTokenA, setPreviousTokenA] = useState(tokenA); diff --git a/src/components/cards/LimitOrderCard.tsx b/src/components/cards/LimitOrderCard.tsx index af8813415..63f1c267e 100644 --- a/src/components/cards/LimitOrderCard.tsx +++ b/src/components/cards/LimitOrderCard.tsx @@ -180,8 +180,8 @@ function LimitOrder({ const formState = useContext(LimitOrderFormContext); const formSetState = useContext(LimitOrderFormSetContext); - const tokenIn = !buyMode ? tokenA : tokenB; - const tokenOut = buyMode ? tokenA : tokenB; + const tokenIn = !buyMode ? tokenB : tokenA; + const tokenOut = buyMode ? tokenB : tokenA; const [denomIn, denomOut] = [getTokenId(tokenIn), getTokenId(tokenOut)]; const { data: userBalanceTokenIn, isLoading: isLoadingUserBalanceTokenIn } = useBankBalanceBaseAmount(denomIn); @@ -550,7 +550,7 @@ function LimitOrder({ formSetState.setAmount?.(value); setTokenInBalanceFraction(undefined); }} - suffix={tokenA?.symbol} + suffix={tokenB?.symbol} format={formatNumericAmount('')} />
@@ -606,7 +606,7 @@ function LimitOrder({ value={formState.limitPrice ?? ''} placeholder="market" onChange={formSetState.setLimitPrice} - suffix={tokenA && tokenB && `${tokenA.symbol}/${tokenB.symbol}`} + suffix={tokenA && tokenB && `${tokenA.symbol} per ${tokenB.symbol}`} format={formatNumericAmount('')} /> @@ -691,7 +691,7 @@ function LimitOrder({ : '-' ) )} - suffix={tokenA && tokenB && `${tokenA.symbol}/${tokenB.symbol}`} + suffix={tokenA && tokenB && `${tokenA.symbol} per ${tokenB.symbol}`} />
@@ -709,7 +709,7 @@ function LimitOrder({ ) : '-' } - suffix={tokenB?.symbol} + suffix={tokenA?.symbol} />
{warning ? ( diff --git a/src/pages/Orderbook/Orderbook.tsx b/src/pages/Orderbook/Orderbook.tsx index 47b23b5fd..94fd798c9 100644 --- a/src/pages/Orderbook/Orderbook.tsx +++ b/src/pages/Orderbook/Orderbook.tsx @@ -26,7 +26,7 @@ export default function OrderbookPage() { function Orderbook() { // change tokens to match pathname - const match = useMatch('/orderbook/:tokenA/:tokenB'); + const match = useMatch('/orderbook/:tokenB/:tokenA'); const { data: denomA } = useDenomFromPathParam(match?.params['tokenA']); const { data: denomB } = useDenomFromPathParam(match?.params['tokenB']); const { data: tokenA } = useToken(denomA); @@ -41,7 +41,7 @@ function Orderbook() {
{tokenA && tokenB && ( - + )}
diff --git a/src/pages/Orderbook/OrderbookChart.tsx b/src/pages/Orderbook/OrderbookChart.tsx index 59686e16a..b14c7e1e6 100644 --- a/src/pages/Orderbook/OrderbookChart.tsx +++ b/src/pages/Orderbook/OrderbookChart.tsx @@ -161,8 +161,8 @@ export default function OrderBookChart({ ) => { const url = new URL( `${REACT_APP__INDEXER_API}/timeseries/price/${encodeURIComponent( - symbolA - )}/${encodeURIComponent(symbolB)}${ + symbolB + )}/${encodeURIComponent(symbolA)}${ resolutionMap[`${resolution}`] ? `/${resolutionMap[`${resolution}`]}` : '' diff --git a/src/pages/Orderbook/OrderbookHeader.tsx b/src/pages/Orderbook/OrderbookHeader.tsx index 638790e1c..a21a67721 100644 --- a/src/pages/Orderbook/OrderbookHeader.tsx +++ b/src/pages/Orderbook/OrderbookHeader.tsx @@ -60,7 +60,7 @@ function OrderbookNav({ tokenA, tokenB }: { tokenA?: Token; tokenB?: Token }) { const setTokensPath = useCallback( ([tokenA, tokenB]: [Token?, Token?]) => { if (tokenA || tokenB) { - const path = [tokenA?.symbol ?? '-', tokenB?.symbol ?? '-']; + const path = [tokenB?.symbol ?? '-', tokenA?.symbol ?? '-']; navigate(`/orderbook/${path.filter(Boolean).join('/')}`); } else { navigate('/orderbook'); @@ -90,24 +90,24 @@ function OrderbookNav({ tokenA, tokenB }: { tokenA?: Token; tokenB?: Token }) { return (
- +

- {tokenA ? : 'Select'} + {tokenB ? : 'Select'} / - {tokenB ? : 'Select'} + {tokenA ? : 'Select'}

{userHasDeposits && (
- + @@ -104,7 +104,7 @@ export default function PoolOverview({
)}
- + @@ -185,7 +185,7 @@ function PairComposition({ tokenA, tokenB }: { tokenA: Token; tokenB: Token }) { ); }, ], - data: [tokenA, tokenB], + data: [tokenB, tokenA], }; }, [amountA, amountB, tokenA, tokenB, valueA, valueB]); @@ -240,8 +240,8 @@ function PoolOverviewTable({ const transactionTableHeadings = [ 'Type', 'Total Value', - 'Token A Amount', 'Token B Amount', + 'Token A Amount', 'Wallet', 'Time', ] as const; diff --git a/src/pages/Pool/Pools.tsx b/src/pages/Pool/Pools.tsx index 35816c638..19e7b16e9 100644 --- a/src/pages/Pool/Pools.tsx +++ b/src/pages/Pool/Pools.tsx @@ -30,8 +30,8 @@ function Pools() { const navigate = useNavigate(); // change tokens to match pathname - const matchTokens = useMatch('/pools/:tokenA/:tokenB'); - const matchTokenManagement = useMatch('/pools/:tokenA/:tokenB/:addOrEdit'); + const matchTokens = useMatch('/pools/:tokenB/:tokenA'); + const matchTokenManagement = useMatch('/pools/:tokenB/:tokenA/:addOrEdit'); const isManagementPath = !!matchTokenManagement && (matchTokenManagement.params['addOrEdit'] === 'add' || From ff81eb5fc0ef6eb8e497e535b98fdd2103d486f5 Mon Sep 17 00:00:00 2001 From: David Uhlmann Date: Sun, 7 Apr 2024 19:23:21 +1000 Subject: [PATCH 05/35] refactor: remove possibility of misaligning order type text values --- src/components/cards/LimitOrderCard.tsx | 13 ++++--------- src/lib/web3/utils/limitOrders.ts | 17 +++++++---------- 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/src/components/cards/LimitOrderCard.tsx b/src/components/cards/LimitOrderCard.tsx index 63f1c267e..36ec1bd25 100644 --- a/src/components/cards/LimitOrderCard.tsx +++ b/src/components/cards/LimitOrderCard.tsx @@ -51,12 +51,13 @@ import { import SelectInput from '../inputs/SelectInput'; import { timeUnits } from '../../lib/utils/time'; import { - inputOrderTypeTextMap, orderTypeEnum, timePeriods, timePeriodLabels, TimePeriod, AllowedLimitOrderTypeKey, + inputOrderTypes, + orderTypeTextMap, } from '../../lib/web3/utils/limitOrders'; import { DexTickUpdateEvent, @@ -625,14 +626,8 @@ function LimitOrder({
className="flex col m-0 p-0" - list={ - Object.keys(inputOrderTypeTextMap) as Array< - keyof typeof inputOrderTypeTextMap - > - } - getLabel={(key = defaultExecutionType) => - key && inputOrderTypeTextMap[key] - } + list={inputOrderTypes} + getLabel={(key = defaultExecutionType) => orderTypeTextMap[key]} value={formState.execution} onChange={formSetState.setExecution} floating diff --git a/src/lib/web3/utils/limitOrders.ts b/src/lib/web3/utils/limitOrders.ts index 9398868e3..c0d56b138 100644 --- a/src/lib/web3/utils/limitOrders.ts +++ b/src/lib/web3/utils/limitOrders.ts @@ -23,16 +23,7 @@ export const orderTypeEnum: { UNRECOGNIZED: -1, }; -export const inputOrderTypeTextMap: Partial<{ - [key in AllowedLimitOrderTypeKey]: string; -}> = { - FILL_OR_KILL: 'Fill Or Kill', - IMMEDIATE_OR_CANCEL: 'Immediate Or Cancel', -}; - -export const orderTypeTextMap: { - [key in keyof typeof LimitOrderType]: string; -} = { +export const orderTypeTextMap: Record = { GOOD_TIL_CANCELLED: 'Good Til Canceled', FILL_OR_KILL: 'Fill Or Kill', IMMEDIATE_OR_CANCEL: 'Immediate Or Cancel', @@ -41,6 +32,12 @@ export const orderTypeTextMap: { UNRECOGNIZED: 'Unrecognized', }; +// restrict the order types available to use in inputs +export const inputOrderTypes: Array = [ + 'FILL_OR_KILL', + 'IMMEDIATE_OR_CANCEL', +]; + export const timePeriods = [ 'seconds', 'minutes', From abad4a3517b03f7fb8cbe04be2430569c5448d16 Mon Sep 17 00:00:00 2001 From: David Uhlmann Date: Mon, 8 Apr 2024 04:19:20 +1000 Subject: [PATCH 06/35] refactor: read swap amout out from result payload instead of events --- src/pages/Swap/hooks/useSwap.tsx | 31 +++++++------------------------ 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/src/pages/Swap/hooks/useSwap.tsx b/src/pages/Swap/hooks/useSwap.tsx index d7d47b6cb..60545fbbf 100644 --- a/src/pages/Swap/hooks/useSwap.tsx +++ b/src/pages/Swap/hooks/useSwap.tsx @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react'; import { DeliverTxResponse } from '@cosmjs/stargate'; -import { OfflineSigner, parseCoins } from '@cosmjs/proto-signing'; +import { OfflineSigner } from '@cosmjs/proto-signing'; import { BigNumber } from 'bignumber.js'; import { formatAmount } from '../../../lib/utils/number'; @@ -10,10 +10,6 @@ import { createTransactionToasts } from '../../../components/Notifications/commo import { getDisplayDenomAmount } from '../../../lib/web3/utils/tokens'; import { useTokenByDenom } from '../../../lib/web3/hooks/useDenomClients'; -import { - mapEventAttributes, - CoinReceivedEvent, -} from '../../../lib/web3/utils/events'; import { getDexSigningClient } from '../../../lib/web3/clients/signingClients'; import { neutron } from '@duality-labs/neutronjs'; import { @@ -149,25 +145,12 @@ export function useSwap(denoms: string[]): [ { onLoadingMessage: 'Executing your trade', onSuccess(res) { - const amountOut = res.events.reduce((result, event) => { - if ( - event.type === 'coin_received' && - event.attributes.find( - ({ key, value }) => key === 'receiver' && value === address - ) - ) { - // collect into more usable format for parsing - const { attributes } = - mapEventAttributes(event); - // parse coin string for matching tokens - const coin = parseCoins(attributes.amount)[0]; - if (coin?.denom === tokenOut) { - return result.plus(coin?.amount || 0); - } - } - return result; - }, new BigNumber(0)); - + const msgResponseValue = res.msgResponses.at(0)?.value; + const response = msgResponseValue + ? neutron.dex.MsgPlaceLimitOrderResponse.decode(msgResponseValue) + : undefined; + const amountOut = + response && Number(response.taker_coin_out.amount); const description = amountOut ? `Received ${formatAmount( getDisplayDenomAmount( From 29cedf4cad5ec3df4a9a1d209e44273709cfcb2b Mon Sep 17 00:00:00 2001 From: David Uhlmann Date: Mon, 8 Apr 2024 04:20:40 +1000 Subject: [PATCH 07/35] feat: add other types of limit orders to be used in inputs --- src/components/cards/LimitOrderCard.tsx | 44 +++++++++++++++++-------- src/lib/web3/utils/limitOrders.ts | 3 ++ 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/components/cards/LimitOrderCard.tsx b/src/components/cards/LimitOrderCard.tsx index 36ec1bd25..af2648f43 100644 --- a/src/components/cards/LimitOrderCard.tsx +++ b/src/components/cards/LimitOrderCard.tsx @@ -391,12 +391,18 @@ function LimitOrder({ function (event?: React.FormEvent) { if (event) event.preventDefault(); - // calculate last price out from result - const lastPriceEvent = simulationResult?.result?.events.findLast( - (event) => event.type === 'TickUpdate' - ); + // define function to get the current market limit index with a tolerance + const getMarketLimitIndex = () => { + // calculate last price out from result + const lastPriceEvent = simulationResult?.result?.events.findLast( + (event) => event.type === 'TickUpdate' + ); + + // if market limit simulation made no update to the liquidity then skip + if (!lastPriceEvent) { + return; + } - if (lastPriceEvent) { const denomIn = getTokenId(tokenIn); // calculate tolerance from user slippage settings @@ -412,23 +418,33 @@ function LimitOrder({ ? lastPrice.TokenIn === lastPrice.TokenZero : lastPrice.TokenIn === lastPrice.TokenOne; - const tickIndexLimitInToOut = direction + return direction ? Math.floor(Number(lastPrice.TickIndex) / toleranceFactor) : Math.floor(Number(lastPrice.TickIndex) * toleranceFactor); + }; - if (simulatedMsgPlaceLimitOrder && tickIndexLimitInToOut) { - const msgPlaceLimitOrder = { - ...simulatedMsgPlaceLimitOrder, - tick_index_in_to_out: Long.fromNumber(tickIndexLimitInToOut), - }; - const gasEstimate = simulationResult?.gasInfo?.gasUsed.toNumber(); - swapRequest(msgPlaceLimitOrder, gasEstimate || 0); - } + const tickIndexLimitInToOut = formState.limitPrice + ? displayPriceToTickIndex( + new BigNumber(formState.limitPrice), + tokenIn, + tokenOut + )?.toNumber() + : getMarketLimitIndex(); + + // if the msg is defined and the limit is ok, then proceed + if (simulatedMsgPlaceLimitOrder && tickIndexLimitInToOut !== undefined) { + const msgPlaceLimitOrder = { + ...simulatedMsgPlaceLimitOrder, + tick_index_in_to_out: Long.fromNumber(tickIndexLimitInToOut), + }; + const gasEstimate = simulationResult?.gasInfo?.gasUsed.toNumber(); + swapRequest(msgPlaceLimitOrder, gasEstimate || 0); } }, [ formState, tokenIn, + tokenOut, simulatedMsgPlaceLimitOrder, simulationResult, swapRequest, diff --git a/src/lib/web3/utils/limitOrders.ts b/src/lib/web3/utils/limitOrders.ts index c0d56b138..af1160178 100644 --- a/src/lib/web3/utils/limitOrders.ts +++ b/src/lib/web3/utils/limitOrders.ts @@ -36,6 +36,9 @@ export const orderTypeTextMap: Record = { export const inputOrderTypes: Array = [ 'FILL_OR_KILL', 'IMMEDIATE_OR_CANCEL', + 'JUST_IN_TIME', + 'GOOD_TIL_TIME', + 'GOOD_TIL_CANCELLED', ]; export const timePeriods = [ From c87f2280958a760ee08b092b8020470e186ceb07 Mon Sep 17 00:00:00 2001 From: David Uhlmann Date: Mon, 8 Apr 2024 05:50:52 +1000 Subject: [PATCH 08/35] fix: add negative limit price check --- src/components/cards/LimitOrderCard.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/cards/LimitOrderCard.tsx b/src/components/cards/LimitOrderCard.tsx index af2648f43..984dbd711 100644 --- a/src/components/cards/LimitOrderCard.tsx +++ b/src/components/cards/LimitOrderCard.tsx @@ -320,6 +320,8 @@ function LimitOrder({ denomOut && amountIn && userBalanceTokenIn && + // if limit price is set it should be above 0 + (isNaN(limitPrice) || Number(limitPrice) > 0) && Number(amountIn) > 0 && // either amount in or out should be more than zero (Number(amountInBaseAmount) > 0 || Number(amountOutBaseAmount) > 0) From ca6233a4f9e9d6ebde05ff49706cbe0283deee68 Mon Sep 17 00:00:00 2001 From: David Uhlmann Date: Mon, 8 Apr 2024 05:51:37 +1000 Subject: [PATCH 09/35] fix: price directions are dependent on buy mode and token order --- src/components/cards/LimitOrderCard.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/cards/LimitOrderCard.tsx b/src/components/cards/LimitOrderCard.tsx index 984dbd711..b665690de 100644 --- a/src/components/cards/LimitOrderCard.tsx +++ b/src/components/cards/LimitOrderCard.tsx @@ -64,6 +64,7 @@ import { mapEventAttributes, } from '../../lib/web3/utils/events'; import { displayPriceToTickIndex } from '../../lib/web3/utils/ticks'; +import { guessInvertedOrder } from '../../lib/web3/utils/pairs'; import Drawer from '../Drawer'; @@ -328,17 +329,17 @@ function LimitOrder({ ) { // when buying: select tick index below the limit // when selling: select tick index above the limit - const rounding = buyMode ? 'floor' : 'ceil'; const limitTickIndexInToOut = limitPrice > 0 ? displayPriceToTickIndex( new BigNumber(limitPrice), tokenOut, tokenIn, - rounding + // change limit rounding direction depending on token direction + guessInvertedOrder([denomIn, denomOut]) ? 'floor' : 'ceil' ) - // change limit direction depending on token direction - ?.multipliedBy(buyMode ? -1 : 1) + // change limit direction depending on buying direction + ?.multipliedBy(buyMode ? 1 : -1) : undefined; const msgPlaceLimitOrder: MsgPlaceLimitOrder = { From 5ca0d786b1d2beaad060807c140d7aca1d41ec31 Mon Sep 17 00:00:00 2001 From: David Uhlmann Date: Mon, 8 Apr 2024 18:33:07 +1000 Subject: [PATCH 10/35] feat: allow indexer stream hooks to include metadata like height --- src/lib/web3/hooks/useIndexer.ts | 50 +++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/src/lib/web3/hooks/useIndexer.ts b/src/lib/web3/hooks/useIndexer.ts index 6c658d928..cebfc5bcf 100644 --- a/src/lib/web3/hooks/useIndexer.ts +++ b/src/lib/web3/hooks/useIndexer.ts @@ -1,5 +1,6 @@ import useSWR from 'swr'; import useSWRSubscription, { SWRSubscription } from 'swr/subscription'; +import { useMemo } from 'react'; import { seconds } from '../../utils/time'; const { REACT_APP__INDEXER_API = '' } = import.meta.env; @@ -437,8 +438,12 @@ export class IndexerStreamAccumulateDualDataSet< } } -interface StaleWhileRevalidateState { +interface StreamMetadata { + height: number; +} +interface StaleWhileRevalidateStreamState { data?: DataSetOrDataSets; + meta?: StreamMetadata; error?: Error; } // add higher-level hook to stream real-time DataSet or DataSets of Indexer URL @@ -449,7 +454,7 @@ function useIndexerStream< url: URL | string | undefined, IndexerClass: typeof IndexerStreamAccumulateSingleDataSet, opts?: AccumulatorOptions -): StaleWhileRevalidateState; +): StaleWhileRevalidateStreamState; function useIndexerStream< DataRow extends BaseDataRow, DataSet = BaseDataSet @@ -457,7 +462,7 @@ function useIndexerStream< url: URL | string | undefined, IndexerClass: typeof IndexerStreamAccumulateDualDataSet, opts?: AccumulatorOptions -): StaleWhileRevalidateState; +): StaleWhileRevalidateStreamState; function useIndexerStream< DataRow extends BaseDataRow, DataSet = BaseDataSet @@ -467,14 +472,15 @@ function useIndexerStream< | typeof IndexerStreamAccumulateSingleDataSet | typeof IndexerStreamAccumulateDualDataSet, opts?: AccumulatorOptions -): StaleWhileRevalidateState { +): StaleWhileRevalidateStreamState { // define subscription callback which may or may not be used in this component // it is passed to useSWRSubscription to handle if the subscription should be // created/held/destroyed as multiple components may listen for the same data - const subscribe: SWRSubscription = ( - [url], - { next } - ) => { + const subscribe: SWRSubscription< + string[], + [DataSet | DataSet[], StreamMetadata], + Error + > = ([url], { next }) => { // if URL is undefined or an empty string, do not stream it if (!url) { return () => undefined; @@ -483,9 +489,9 @@ function useIndexerStream< url, { // note: dataSet may be empty on return of an initial "no rows" payload - onAccumulated: (dataSet) => { + onAccumulated: (dataSet, height) => { // note: the TypeScript here is a bit hacky but this should be ok - next(null, dataSet as unknown as DataSet | DataSet[]); + next(null, [dataSet as unknown as DataSet | DataSet[], { height }]); }, onError: (error) => next(error), }, @@ -500,12 +506,22 @@ function useIndexerStream< }; // return cached subscription data - return useSWRSubscription( + const streamState = useSWRSubscription<[DataSet | DataSet[], StreamMetadata]>( // create key from URL and options [`${url}`, IndexerClass, ...Object.entries(opts || {}).flat()], // the subscription object will differ for any given options subscribe ); + + // extract out meta data into an additional property + return useMemo>( + () => ({ + ...streamState, + data: streamState.data?.[0], + meta: streamState.data?.[1], + }), + [streamState] + ); } // higher-level hook to stream real-time DataSet of Indexer URL @@ -529,7 +545,7 @@ export function useIndexerStreamOfDualDataSet< url, IndexerStreamAccumulateDualDataSet, opts - ) as StaleWhileRevalidateState<[DataSet, DataSet]>; + ) as StaleWhileRevalidateStreamState<[DataSet, DataSet]>; } function useIndexerStreamLastUpdate< @@ -541,7 +557,7 @@ function useIndexerStreamLastUpdate< | typeof IndexerStreamAccumulateSingleDataSet | typeof IndexerStreamAccumulateDualDataSet, opts?: AccumulatorOptions -): StaleWhileRevalidateState { +): StaleWhileRevalidateStreamState { // return cached subscription data const hook = IndexerClass === IndexerStreamAccumulateDualDataSet @@ -560,7 +576,7 @@ export function useIndexerStreamLastUpdateOfSingleDataSet< >( url: URL | string | undefined, opts?: AccumulatorOptions -): StaleWhileRevalidateState { +): StaleWhileRevalidateStreamState { // return cached subscription data return useIndexerStreamLastUpdate( url, @@ -569,7 +585,7 @@ export function useIndexerStreamLastUpdateOfSingleDataSet< accumulateUpdates: accumulateLastUpdateOnly, ...opts, } - ) as StaleWhileRevalidateState; + ) as StaleWhileRevalidateStreamState; } export function useIndexerStreamLastUpdateOfDualDataSet< DataRow extends BaseDataRow, @@ -577,7 +593,7 @@ export function useIndexerStreamLastUpdateOfDualDataSet< >( url: URL | string | undefined, opts?: AccumulatorOptions -): StaleWhileRevalidateState<[DataSet, DataSet]> { +): StaleWhileRevalidateStreamState<[DataSet, DataSet]> { // return cached subscription data return useIndexerStreamLastUpdate( url, @@ -586,7 +602,7 @@ export function useIndexerStreamLastUpdateOfDualDataSet< accumulateUpdates: accumulateLastUpdateOnly, ...opts, } - ) as StaleWhileRevalidateState<[DataSet, DataSet]>; + ) as StaleWhileRevalidateStreamState<[DataSet, DataSet]>; } // add higher-level functions to fetch multiple pages of data as "one request" From 05f8eae2efa9556592a0c6ba9944e6884d81f3a1 Mon Sep 17 00:00:00 2001 From: David Uhlmann Date: Mon, 8 Apr 2024 18:51:33 +1000 Subject: [PATCH 11/35] fix: update simulated place limit order on each liquidity movement --- src/components/cards/LimitOrderCard.tsx | 12 ++++++++++-- src/lib/web3/hooks/useIndexer.ts | 2 +- src/lib/web3/hooks/useTickLiquidity.ts | 10 +++++----- src/lib/web3/utils/memo.ts | 12 ++++++++++++ src/pages/Swap/hooks/useRouter.ts | 9 ++++++++- 5 files changed, 36 insertions(+), 9 deletions(-) create mode 100644 src/lib/web3/utils/memo.ts diff --git a/src/components/cards/LimitOrderCard.tsx b/src/components/cards/LimitOrderCard.tsx index b665690de..8aa142f5e 100644 --- a/src/components/cards/LimitOrderCard.tsx +++ b/src/components/cards/LimitOrderCard.tsx @@ -40,6 +40,7 @@ import { } from '../../lib/web3/hooks/useUserBankBalances'; import { useChainFeeToken } from '../../lib/web3/hooks/useTokens'; import { useNativeChain } from '../../lib/web3/hooks/useChains'; +import { useTokenPairMapLiquidity } from '../../lib/web3/hooks/useTickLiquidity'; import { useCurrentPriceFromTicks } from '../Liquidity/useCurrentPriceFromTicks'; import RangeListSliderInput from '../inputs/RangeInput/RangeListSliderInput'; @@ -189,6 +190,7 @@ function LimitOrder({ useBankBalanceBaseAmount(denomIn); const { data: userBalanceTokenInDisplayAmount } = useBankBalanceDisplayAmount(denomIn); + const { meta } = useTokenPairMapLiquidity([denomA, denomB]); const [{ isValidating: isValidatingSwap, error }, swapRequest] = useSwap( [denomA, denomB].filter((denom): denom is string => !!denom) @@ -229,7 +231,10 @@ function LimitOrder({ const { data: buyAmountSimulationResult, isValidating: isValidatingBuyAmountSimulationResult, - } = useSimulatedLimitOrderResult(buyAmountSimulatedMsgPlaceLimitOrder); + } = useSimulatedLimitOrderResult(buyAmountSimulatedMsgPlaceLimitOrder, { + // invalidate the request cache when the liquidity height changes + memo: `height of ${meta?.height}`, + }); const { amountInDisplayAmount, @@ -388,7 +393,10 @@ function LimitOrder({ ]); const { data: simulationResult, isValidating: isValidatingSimulation } = - useSimulatedLimitOrderResult(simulatedMsgPlaceLimitOrder); + useSimulatedLimitOrderResult(simulatedMsgPlaceLimitOrder, { + // invalidate the request cache when the liquidity height changes + memo: `height of ${meta?.height}`, + }); const onFormSubmit = useCallback( function (event?: React.FormEvent) { diff --git a/src/lib/web3/hooks/useIndexer.ts b/src/lib/web3/hooks/useIndexer.ts index cebfc5bcf..dc47bf9c6 100644 --- a/src/lib/web3/hooks/useIndexer.ts +++ b/src/lib/web3/hooks/useIndexer.ts @@ -441,7 +441,7 @@ export class IndexerStreamAccumulateDualDataSet< interface StreamMetadata { height: number; } -interface StaleWhileRevalidateStreamState { +export interface StaleWhileRevalidateStreamState { data?: DataSetOrDataSets; meta?: StreamMetadata; error?: Error; diff --git a/src/lib/web3/hooks/useTickLiquidity.ts b/src/lib/web3/hooks/useTickLiquidity.ts index cc410d4e2..14ad22dd3 100644 --- a/src/lib/web3/hooks/useTickLiquidity.ts +++ b/src/lib/web3/hooks/useTickLiquidity.ts @@ -5,7 +5,10 @@ import BigNumber from 'bignumber.js'; import { TickInfo, tickIndexToPrice } from '../utils/ticks'; import { useOrderedTokenPair } from './useTokenPairs'; -import { useIndexerStreamOfDualDataSet } from './useIndexer'; +import { + StaleWhileRevalidateStreamState, + useIndexerStreamOfDualDataSet, +} from './useIndexer'; import { Token, TokenID } from '../utils/tokens'; import { useToken } from './useDenomClients'; @@ -16,10 +19,7 @@ type ReserveDataSet = Map; export function useTokenPairMapLiquidity([tokenIdA, tokenIdB]: [ TokenID?, TokenID? -]): { - data?: [ReserveDataSet, ReserveDataSet]; - error?: unknown; -} { +]): StaleWhileRevalidateStreamState<[ReserveDataSet, ReserveDataSet]> { const encodedA = tokenIdA && encodeURIComponent(tokenIdA); const encodedB = tokenIdB && encodeURIComponent(tokenIdB); // stream data from indexer diff --git a/src/lib/web3/utils/memo.ts b/src/lib/web3/utils/memo.ts new file mode 100644 index 000000000..a52078e73 --- /dev/null +++ b/src/lib/web3/utils/memo.ts @@ -0,0 +1,12 @@ +const { REACT_APP__APP_VERSION = '' } = import.meta.env; + +export function createMemo(message?: string): string { + const memo: Record = { + app: `${location.origin}${location.pathname}`, + version: REACT_APP__APP_VERSION, + }; + if (message) { + memo.message = message; + } + return JSON.stringify(memo); +} diff --git a/src/pages/Swap/hooks/useRouter.ts b/src/pages/Swap/hooks/useRouter.ts index b3e2d6487..4122489bd 100644 --- a/src/pages/Swap/hooks/useRouter.ts +++ b/src/pages/Swap/hooks/useRouter.ts @@ -14,6 +14,7 @@ import { } from '../../../lib/web3/clients/signingClients'; import { useSwrResponseFromReactQuery } from '../../../lib/web3/hooks/useSWR'; +import { createMemo } from '../../../lib/web3/utils/memo'; type SimulateResponse = Awaited>; type ExtendedSimulateResponse = Partial< @@ -50,11 +51,17 @@ export function useSimulatedLimitOrderResult( // use signing client simulation function to get simulated response and gas const { wallet, address } = useWeb3(); const txSimulationClient = useTxSimulationClient(wallet); + const memo = createMemo(opts.memo); const result = useQuery< ExtendedSimulateResponse | undefined, LimitOrderTxSimulationError >({ - queryKey: [txSimulationClient, address, JSON.stringify(msgPlaceLimitOrder)], + queryKey: [ + txSimulationClient, + address, + JSON.stringify(msgPlaceLimitOrder), + memo, + ], enabled: Boolean(txSimulationClient && address && msgPlaceLimitOrder), queryFn: async (): Promise => { // early exit to help types, should match "enabled" property condition From a2c3a50d3d5501cfb366bdb4398807db3f23a382 Mon Sep 17 00:00:00 2001 From: David Uhlmann Date: Mon, 8 Apr 2024 18:53:15 +1000 Subject: [PATCH 12/35] fix: improve loading states during continuous limit order simulations --- src/components/cards/LimitOrderCard.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/components/cards/LimitOrderCard.tsx b/src/components/cards/LimitOrderCard.tsx index 8aa142f5e..c1039193d 100644 --- a/src/components/cards/LimitOrderCard.tsx +++ b/src/components/cards/LimitOrderCard.tsx @@ -1,5 +1,7 @@ import Long from 'long'; import BigNumber from 'bignumber.js'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; import { createContext, useCallback, @@ -392,11 +394,19 @@ function LimitOrder({ userBalanceTokenIn, ]); + const lastSimulatedMsgPlaceLimitOrder = useRef(); const { data: simulationResult, isValidating: isValidatingSimulation } = useSimulatedLimitOrderResult(simulatedMsgPlaceLimitOrder, { + // if the limit order payload hasn't changed then keep the previous data + keepPreviousData: + lastSimulatedMsgPlaceLimitOrder.current === simulatedMsgPlaceLimitOrder, // invalidate the request cache when the liquidity height changes memo: `height of ${meta?.height}`, }); + // update last simulatedMsgPlaceLimitOrder for comparison on the next render + useEffect(() => { + lastSimulatedMsgPlaceLimitOrder.current = simulatedMsgPlaceLimitOrder; + }, [simulatedMsgPlaceLimitOrder]); const onFormSubmit = useCallback( function (event?: React.FormEvent) { @@ -719,6 +729,7 @@ function LimitOrder({
{tooltip}}
-
{value}
+
+ {loading && } +
+
{value}
{suffix &&
{suffix}
} ); From 09026559cb0d5c0c73cd9b6ca5461e9d0de4aa7b Mon Sep 17 00:00:00 2001 From: David Uhlmann Date: Tue, 9 Apr 2024 10:59:08 +1000 Subject: [PATCH 13/35] fix: calculate approx non-immediate limit order coin out for previews --- src/components/cards/LimitOrderCard.tsx | 57 +++++++++++++++++++------ src/lib/web3/utils/limitOrders.ts | 10 +++++ src/pages/Swap/hooks/useRouter.ts | 32 +++++++++++++- 3 files changed, 85 insertions(+), 14 deletions(-) diff --git a/src/components/cards/LimitOrderCard.tsx b/src/components/cards/LimitOrderCard.tsx index c1039193d..91de0b451 100644 --- a/src/components/cards/LimitOrderCard.tsx +++ b/src/components/cards/LimitOrderCard.tsx @@ -61,12 +61,16 @@ import { AllowedLimitOrderTypeKey, inputOrderTypes, orderTypeTextMap, + nonImmediateOrderTypes, } from '../../lib/web3/utils/limitOrders'; import { DexTickUpdateEvent, mapEventAttributes, } from '../../lib/web3/utils/events'; -import { displayPriceToTickIndex } from '../../lib/web3/utils/ticks'; +import { + displayPriceToTickIndex, + tickIndexToPrice, +} from '../../lib/web3/utils/ticks'; import { guessInvertedOrder } from '../../lib/web3/utils/pairs'; import Drawer from '../Drawer'; @@ -572,6 +576,31 @@ function LimitOrder({ const currentPriceFromTicks = useCurrentPriceFromTicks(denomIn, denomOut); + // find coinIn and coinOut from the simulation results + const tranchedReserves = + (simulatedMsgPlaceLimitOrder && + nonImmediateOrderTypes.includes( + simulatedMsgPlaceLimitOrder.order_type as number + ) && + simulationResult?.response?.tranchedReserves) || + 0; + // determine the amount of coin out that should be possible from tranches + const tranchedAmountOut = + tranchedReserves / + (simulatedMsgPlaceLimitOrder?.tick_index_in_to_out + ? // use the limit price + tickIndexToPrice( + new BigNumber( + simulatedMsgPlaceLimitOrder.tick_index_in_to_out.toNumber() + ) + ).toNumber() + : // use the market price or NaN + currentPriceFromTicks?.toNumber() ?? 0) || 0; + const coinIn = Number(simulationResult?.response?.coin_in.amount || 0); + const coinOut = + Number(simulationResult?.response?.taker_coin_out.amount || 0) + + tranchedAmountOut; + // disable fieldset with no address because the estimation requires a signed client const fieldset = (
@@ -714,12 +743,8 @@ function LimitOrder({ formatMaximumSignificantDecimals( simulationResult?.response ? !buyMode - ? new BigNumber( - simulationResult.response.taker_coin_out.amount - ).div(simulationResult.response.coin_in.amount) - : new BigNumber(simulationResult.response.coin_in.amount).div( - simulationResult.response.taker_coin_out.amount - ) + ? coinOut / coinIn || '-' + : coinIn / coinOut || '-' : '-' ) )} @@ -731,13 +756,11 @@ function LimitOrder({ prefix={`Total ${buyMode ? 'Cost' : 'Result'}`} loading={isValidatingSimulation} value={ - tokenIn && simulationResult?.response + tokenIn && simulationResult ? formatAmount( getDisplayDenomAmount( tokenIn, - buyMode - ? simulationResult.response.coin_in.amount - : simulationResult.response.taker_coin_out.amount + buyMode ? coinIn || '-' : coinOut || '-' ) || 0 ) : '-' @@ -745,7 +768,16 @@ function LimitOrder({ suffix={tokenA?.symbol} /> - {warning ? ( + {Number(formState.amount) && simulationResult && !coinIn ? ( + // show a warning if an amount in, but no amount out +
+
+

No trade will occur with this type of order

+

and this limit price at this time

+

Choose a limit closer to the current price

+
+
+ ) : warning ? ( // show a warning if an amount has been entered, but the form fails validation
{warning}
@@ -762,6 +794,7 @@ function LimitOrder({ disabled={ isValidatingSwap || !simulationResult || + !coinOut || (!Number(amountInBaseAmount) && !Number(amountOutBaseAmount)) } > diff --git a/src/lib/web3/utils/limitOrders.ts b/src/lib/web3/utils/limitOrders.ts index af1160178..edbde43bc 100644 --- a/src/lib/web3/utils/limitOrders.ts +++ b/src/lib/web3/utils/limitOrders.ts @@ -41,6 +41,16 @@ export const inputOrderTypes: Array = [ 'GOOD_TIL_CANCELLED', ]; +export const immediateOrderTypes = [ + orderTypeEnum.FILL_OR_KILL, + orderTypeEnum.IMMEDIATE_OR_CANCEL, +] as const; +export const nonImmediateOrderTypes = [ + orderTypeEnum.JUST_IN_TIME, + orderTypeEnum.GOOD_TIL_TIME, + orderTypeEnum.GOOD_TIL_CANCELLED, +] as const; + export const timePeriods = [ 'seconds', 'minutes', diff --git a/src/pages/Swap/hooks/useRouter.ts b/src/pages/Swap/hooks/useRouter.ts index 4122489bd..028d06ccc 100644 --- a/src/pages/Swap/hooks/useRouter.ts +++ b/src/pages/Swap/hooks/useRouter.ts @@ -15,11 +15,18 @@ import { import { useSwrResponseFromReactQuery } from '../../../lib/web3/hooks/useSWR'; import { createMemo } from '../../../lib/web3/utils/memo'; +import { + DexPlaceLimitOrderEvent, + mapEventAttributes, +} from '../../../lib/web3/utils/events'; type SimulateResponse = Awaited>; type ExtendedSimulateResponse = Partial< SimulateResponse & { - response: MsgPlaceLimitOrderResponse; + response: MsgPlaceLimitOrderResponse & { + tranchedReserves: number; + swappedReserves: number; + }; error: LimitOrderTxSimulationError; } >; @@ -83,13 +90,34 @@ export function useSimulatedLimitOrderResult( const response = neutron.dex.MsgPlaceLimitOrderResponse.decode( result.msgResponses[0].value ); + const placeLimitOrderEvent = result.events + .map(mapEventAttributes) + .find((event) => event.attributes.action === 'PlaceLimitOrder'); + // determine the amount of reserves that were added to tranches, + // these reserves have not yet been traded (so we may estimate them) + // note: hopefully tranchedReserves can be added to the + // MsgPlaceLimitOrderResponse payload so that we don't need to derive it here + const tranchedReserves = Number( + placeLimitOrderEvent?.attributes.Shares || 0 + ); + const swappedReserves = + Number(placeLimitOrderEvent?.attributes.AmountIn || 0) - + tranchedReserves; + // add liquidity error if appropriate const error = new BigNumber(response.coin_in.amount) .multipliedBy(1.01) .isLessThan(msgPlaceLimitOrder.amount_in) ? new LimitOrderTxSimulationError(FILL_OR_KILL_ERROR) : undefined; - return { gasInfo, result, response, error }; + // note: hopefully tranchedReserves can be added to the + // MsgPlaceLimitOrderResponse payload so that we don't need to derive it here + return { + gasInfo, + result, + response: { ...response, tranchedReserves, swappedReserves }, + error, + }; } // likely an error result return { gasInfo, result }; From e94af7d475f96d56551314420a9f700655047194 Mon Sep 17 00:00:00 2001 From: David Uhlmann Date: Tue, 9 Apr 2024 11:09:13 +1000 Subject: [PATCH 14/35] Revert "fix: reduce FILL_OR_KILL bugs in simulations temporarily" This reverts commit cb8e7eaa403e1a71d9110083414ae46c001067f5. --- src/pages/Swap/Swap.tsx | 10 ++-------- src/pages/Swap/hooks/useRouter.ts | 16 ++++------------ 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/src/pages/Swap/Swap.tsx b/src/pages/Swap/Swap.tsx index 2046f1cf5..29d373830 100644 --- a/src/pages/Swap/Swap.tsx +++ b/src/pages/Swap/Swap.tsx @@ -135,10 +135,7 @@ function Swap() { creator: address, receiver: address, // using type FILL_OR_KILL so that partially filled requests fail - // note: using IMMEDIATE_OR_CANCEL while FILL_OR_KILL has a bug - // that often can result in rounding errors and incomplete orders - // revert this (non-squashed commit) when it is fixed - order_type: orderTypeEnum.IMMEDIATE_OR_CANCEL, + order_type: orderTypeEnum.FILL_OR_KILL, // trade as far as we can go tick_index_in_to_out: Long.fromNumber(priceMaxIndex), }; @@ -261,10 +258,7 @@ function Swap() { { ...swapMsg, // using type FILL_OR_KILL so that partially filled requests fail - // note: using IMMEDIATE_OR_CANCEL while FILL_OR_KILL has a bug - // that often can result in rounding errors and incomplete orders - // revert this (non-squashed commit) when it is fixed - order_type: orderTypeEnum.IMMEDIATE_OR_CANCEL, + order_type: orderTypeEnum.FILL_OR_KILL, tick_index_in_to_out: Long.fromNumber(tickIndexLimitInToOut), max_amount_out: amountOut.toFixed(0), }, diff --git a/src/pages/Swap/hooks/useRouter.ts b/src/pages/Swap/hooks/useRouter.ts index 028d06ccc..10920541a 100644 --- a/src/pages/Swap/hooks/useRouter.ts +++ b/src/pages/Swap/hooks/useRouter.ts @@ -1,4 +1,3 @@ -import BigNumber from 'bignumber.js'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { TxExtension } from '@cosmjs/stargate'; import { neutron } from '@duality-labs/neutronjs'; @@ -31,9 +30,6 @@ type ExtendedSimulateResponse = Partial< } >; -const FILL_OR_KILL_ERROR = - // eslint-disable-next-line quotes - "Fill Or Kill limit order couldn't be executed in its entirety"; class LimitOrderTxSimulationError extends TxSimulationError { insufficientLiquidity: boolean; constructor(error: unknown) { @@ -41,7 +37,10 @@ class LimitOrderTxSimulationError extends TxSimulationError { this.name = 'LimitOrderTxSimulationError'; // parse out message codes - this.insufficientLiquidity = this.message.includes(FILL_OR_KILL_ERROR); + this.insufficientLiquidity = this.message.includes( + // eslint-disable-next-line quotes + "Fill Or Kill limit order couldn't be executed in its entirety" + ); } } @@ -104,19 +103,12 @@ export function useSimulatedLimitOrderResult( Number(placeLimitOrderEvent?.attributes.AmountIn || 0) - tranchedReserves; - // add liquidity error if appropriate - const error = new BigNumber(response.coin_in.amount) - .multipliedBy(1.01) - .isLessThan(msgPlaceLimitOrder.amount_in) - ? new LimitOrderTxSimulationError(FILL_OR_KILL_ERROR) - : undefined; // note: hopefully tranchedReserves can be added to the // MsgPlaceLimitOrderResponse payload so that we don't need to derive it here return { gasInfo, result, response: { ...response, tranchedReserves, swappedReserves }, - error, }; } // likely an error result From 7c2274bf8f09852aeddc79e5daf643007773a139 Mon Sep 17 00:00:00 2001 From: David Uhlmann Date: Tue, 9 Apr 2024 12:26:33 +1000 Subject: [PATCH 15/35] fix: display insufficient liquidity only when the error is encountered --- src/components/cards/LimitOrderCard.tsx | 83 ++++++++++++++----------- 1 file changed, 45 insertions(+), 38 deletions(-) diff --git a/src/components/cards/LimitOrderCard.tsx b/src/components/cards/LimitOrderCard.tsx index 91de0b451..69dd34316 100644 --- a/src/components/cards/LimitOrderCard.tsx +++ b/src/components/cards/LimitOrderCard.tsx @@ -62,6 +62,7 @@ import { inputOrderTypes, orderTypeTextMap, nonImmediateOrderTypes, + immediateOrderTypes, } from '../../lib/web3/utils/limitOrders'; import { DexTickUpdateEvent, @@ -299,6 +300,9 @@ function LimitOrder({ userBalanceTokenIn, ]); + // todo: replace currentPriceFromTicks with realtimePrice from useRealtimePrice + const currentPriceFromTicks = useCurrentPriceFromTicks(denomIn, denomOut); + const simulatedMsgPlaceLimitOrder: MsgPlaceLimitOrder | undefined = useMemo(() => { const [denomIn, denomOut] = [getTokenId(tokenIn), getTokenId(tokenOut)]; @@ -367,13 +371,33 @@ function LimitOrder({ : Long.fromNumber(priceMaxIndex), }; // optional params - // only add maxOut for "taker" (immediate) orders - if ( - tokenOut && - maxAmountOut && - (execution === 'FILL_OR_KILL' || execution === 'IMMEDIATE_OR_CANCEL') - ) { - msgPlaceLimitOrder.max_amount_out = maxAmountOut; + // only add maxOut for buy orders + if (tokenOut && maxAmountOut && currentPriceFromTicks) { + // set max out on taker orders + if ( + immediateOrderTypes.includes(orderTypeEnum[execution] as number) + ) { + // note: if attempting to pass a non-immediate order, fails with error + // "MaxAmountOut can only be set for taker only limit orders." + msgPlaceLimitOrder.max_amount_out = maxAmountOut; + } + // set an estimated max in instead for non-immediate orders + else { + // note: this is a bit or an awkward estimation that goes from the + // user's set limit to the current price + const estimatedTokenIn = + Number(maxAmountOut) * + BigNumber[buyMode ? 'min' : 'max']( + tickIndexToPrice( + limitTickIndexInToOut ?? new BigNumber(priceMaxIndex) + ), + currentPriceFromTicks + ).toNumber(); + msgPlaceLimitOrder.amount_in = BigNumber.min( + msgPlaceLimitOrder.amount_in, + estimatedTokenIn + ).toFixed(0); + } } // only add expiration time to timed limit orders if (execution === 'GOOD_TIL_TIME' && !isNaN(expirationTimeMs)) { @@ -389,6 +413,7 @@ function LimitOrder({ amountInBaseAmount, amountOutBaseAmount, buyMode, + currentPriceFromTicks, formState.execution, formState.limitPrice, formState.timeAmount, @@ -518,34 +543,18 @@ function LimitOrder({ userBalanceTokenInDisplayAmount || '?' )}${tokenIn?.symbol}`; } + } - // check for insufficient liquidity - if ( - // check if less was used than expected - (amountInBaseAmount !== undefined && - new BigNumber(simulationResult.response.coin_in.amount) - // make up for possible rounding on Dex (note this is inaccurate) - .multipliedBy(1 + tolerance) - .isLessThan(amountInBaseAmount)) || - // check if less was found than expected - (amountOutBaseAmount !== undefined && - new BigNumber(simulationResult.response.taker_coin_out.amount) - // make up for possible rounding on Dex (note this is inaccurate) - .multipliedBy(1 + tolerance) - .isLessThan(amountOutBaseAmount)) - ) { - return `Insufficient liquidity: max ${formatAmount( - simulationResult.response.coin_in.amount, - { - useGrouping: true, - } - )}${tokenIn?.symbol} used`; - } + if ( + Number(amountInBaseAmount) && + simulationResult?.error?.insufficientLiquidity + ) { + return 'Insufficient Liquidity: cannot complete order'; } + return undefined; }, [ amountInBaseAmount, - amountOutBaseAmount, buyMode, simulatedMsgPlaceLimitOrder, simulationResult, @@ -574,8 +583,6 @@ function LimitOrder({ } }, [simulationResult?.response]); - const currentPriceFromTicks = useCurrentPriceFromTicks(denomIn, denomOut); - // find coinIn and coinOut from the simulation results const tranchedReserves = (simulatedMsgPlaceLimitOrder && @@ -768,7 +775,12 @@ function LimitOrder({ suffix={tokenA?.symbol} />
- {Number(formState.amount) && simulationResult && !coinIn ? ( + {warning ? ( + // show a warning if an amount has been entered, but the form fails validation +
+
{warning}
+
+ ) : Number(formState.amount) && simulationResult && !coinIn ? ( // show a warning if an amount in, but no amount out
@@ -777,11 +789,6 @@ function LimitOrder({

Choose a limit closer to the current price

- ) : warning ? ( - // show a warning if an amount has been entered, but the form fails validation -
-
{warning}
-
) : error ? (
{error}
From 6dd0796a25035a25c9bbd5916b2b7e4ce9da33d2 Mon Sep 17 00:00:00 2001 From: David Uhlmann Date: Tue, 9 Apr 2024 12:46:27 +1000 Subject: [PATCH 16/35] feat: improve loading state keeping previous preview results --- src/components/cards/LimitOrderCard.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/cards/LimitOrderCard.tsx b/src/components/cards/LimitOrderCard.tsx index 69dd34316..cfdd4e403 100644 --- a/src/components/cards/LimitOrderCard.tsx +++ b/src/components/cards/LimitOrderCard.tsx @@ -240,6 +240,8 @@ function LimitOrder({ isValidating: isValidatingBuyAmountSimulationResult, } = useSimulatedLimitOrderResult(buyAmountSimulatedMsgPlaceLimitOrder, { // invalidate the request cache when the liquidity height changes + keepPreviousData: + Number(buyAmountSimulatedMsgPlaceLimitOrder?.amount_in) > 0, memo: `height of ${meta?.height}`, }); @@ -428,7 +430,14 @@ function LimitOrder({ useSimulatedLimitOrderResult(simulatedMsgPlaceLimitOrder, { // if the limit order payload hasn't changed then keep the previous data keepPreviousData: - lastSimulatedMsgPlaceLimitOrder.current === simulatedMsgPlaceLimitOrder, + // don't keep when input goes to 0 + (Number(simulatedMsgPlaceLimitOrder?.amount_in) > 0 && + // if in buy amount calculation mode let it pass + buyAmountSimulationResult?.response !== undefined) || + // don't change if input exists and hasn't changed + (simulatedMsgPlaceLimitOrder !== undefined && + lastSimulatedMsgPlaceLimitOrder.current === + simulatedMsgPlaceLimitOrder), // invalidate the request cache when the liquidity height changes memo: `height of ${meta?.height}`, }); From 0e32ace271e050fc69e4b12857979beb6b5ace23 Mon Sep 17 00:00:00 2001 From: David Uhlmann Date: Tue, 9 Apr 2024 12:59:33 +1000 Subject: [PATCH 17/35] fix: correct the range slider input in buy mode for token out --- src/components/cards/LimitOrderCard.tsx | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/components/cards/LimitOrderCard.tsx b/src/components/cards/LimitOrderCard.tsx index cfdd4e403..9b1d34ebb 100644 --- a/src/components/cards/LimitOrderCard.tsx +++ b/src/components/cards/LimitOrderCard.tsx @@ -195,6 +195,7 @@ function LimitOrder({ const [denomIn, denomOut] = [getTokenId(tokenIn), getTokenId(tokenOut)]; const { data: userBalanceTokenIn, isLoading: isLoadingUserBalanceTokenIn } = useBankBalanceBaseAmount(denomIn); + const { data: userBalanceTokenOut } = useBankBalanceBaseAmount(denomOut); const { data: userBalanceTokenInDisplayAmount } = useBankBalanceDisplayAmount(denomIn); const { meta } = useTokenPairMapLiquidity([denomA, denomB]); @@ -645,16 +646,22 @@ function LimitOrder({ } value={ tokenInBalanceFraction || - new BigNumber( - // use amount given from text input or range slider input - amountInBaseAmount || - // estimate amount from price - new BigNumber(amountOutBaseAmount || 0).multipliedBy( - lastKnownPrice || currentPriceFromTicks || 0 + (buyMode + ? // in buy mode estimate the fraction of the token out + Number(userBalanceTokenOut) + ? Number(amountOutBaseAmount) / Number(userBalanceTokenOut) || 0 + : 0 + : // in sell mode estimate the fraction of the token in + new BigNumber( + // use amount given from text input or range slider input + amountInBaseAmount || + // estimate amount from price + new BigNumber(amountOutBaseAmount || 0).multipliedBy( + lastKnownPrice || currentPriceFromTicks || 0 + ) ) - ) - .dividedBy(userBalanceTokenIn || 1) - .toNumber() || + .dividedBy(userBalanceTokenIn || 1) + .toNumber()) || 0 } onChange={useCallback( From adcae39b00d7a67544027dcd4b52de2032d14a72 Mon Sep 17 00:00:00 2001 From: David Uhlmann Date: Tue, 9 Apr 2024 20:42:47 +1000 Subject: [PATCH 18/35] fix: dropdown click behavior: don't blur when Drawer is clicked --- src/components/inputs/SelectInput/SelectInput.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/inputs/SelectInput/SelectInput.tsx b/src/components/inputs/SelectInput/SelectInput.tsx index 0b04c0e84..ab722dc3a 100644 --- a/src/components/inputs/SelectInput/SelectInput.tsx +++ b/src/components/inputs/SelectInput/SelectInput.tsx @@ -87,18 +87,18 @@ export default function SelectInput({ () => setExpanded((expanded) => !expanded), [] ); - const close = useCallback(() => setExpanded(false), []); + const close = useCallback(() => setTimeout(() => setExpanded(false), 1), []); return (