diff --git a/package.json b/package.json index 1a28d72..09dcd54 100644 --- a/package.json +++ b/package.json @@ -37,9 +37,9 @@ "@cosmjs/proto-signing": "^0.31.0", "@cosmjs/stargate": "^0.31.0", "@cosmjs/tendermint-rpc": "^0.31.0", - "@dydxprotocol/v4-abacus": "^0.4.28", + "@dydxprotocol/v4-abacus": "^0.5.0", "@dydxprotocol/v4-client-js": "^0.36.1", - "@dydxprotocol/v4-localization": "^0.1.8", + "@dydxprotocol/v4-localization": "^0.1.11", "@ethersproject/providers": "^5.7.2", "@js-joda/core": "^5.5.3", "@radix-ui/react-collapsible": "^1.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7f1e02..31b271b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,14 +27,14 @@ dependencies: specifier: ^0.31.0 version: 0.31.0 '@dydxprotocol/v4-abacus': - specifier: ^0.4.28 - version: 0.4.28 + specifier: ^0.5.0 + version: 0.5.0 '@dydxprotocol/v4-client-js': specifier: ^0.36.1 version: 0.36.1 '@dydxprotocol/v4-localization': - specifier: ^0.1.8 - version: 0.1.8 + specifier: ^0.1.11 + version: 0.1.11 '@ethersproject/providers': specifier: ^5.7.2 version: 5.7.2 @@ -979,8 +979,8 @@ packages: resolution: {integrity: sha512-RpfLEtTlyIxeNPGKcokS+p3BZII/Q3bYxryFRglh5H3A3T8q9fsLYm72VYAMEOOIBLEa8o93kFLiBDUWKrwXZA==} dev: true - /@dydxprotocol/v4-abacus@0.4.28: - resolution: {integrity: sha512-RQGTXI7q4HAXDmlUpDhpJDLN8C0dFMgKHzFITK1Sz3KnLb3mkkpqEU1D8VIn/hB7ngt+equMsLFSkUwzXCzgdA==} + /@dydxprotocol/v4-abacus@0.5.0: + resolution: {integrity: sha512-K/aLJeOWzmToCn6p9vjBqfwn79/nuKxjZLSzaj1mKNxw25KBFh2q93njNbONFRA72zBElD0K+gw8J/skTpGg1Q==} dev: false /@dydxprotocol/v4-client-js@0.36.1: @@ -1010,8 +1010,8 @@ packages: - utf-8-validate dev: false - /@dydxprotocol/v4-localization@0.1.8: - resolution: {integrity: sha512-ZuM/V2tLVSWyi9pDZLOu8h6HScrzKlFftQ/1iBPNIOU4bBMx83vYgokm0X9hzueWzz0PJ2pzT+EoN81sPlN2bg==} + /@dydxprotocol/v4-localization@0.1.11: + resolution: {integrity: sha512-eW88t8KJuDT0fNEgaw615+n3gEYxDZYPeHhdBYquVDXjnkhLNznaH8ftmlLPVPyfu7o5aFeU/cw8ZiqRrf/Stw==} dev: false /@dydxprotocol/v4-proto@0.2.1: diff --git a/src/constants/abacus.ts b/src/constants/abacus.ts index 847dd28..308578b 100644 --- a/src/constants/abacus.ts +++ b/src/constants/abacus.ts @@ -40,6 +40,10 @@ export type AbacusFileSystemProtocol = Omit< Abacus.exchange.dydx.abacus.protocols.FileSystemProtocol, '__doNotUseOrImplementIt' >; +export type AbacusTrackingProtocol = Omit< + Abacus.exchange.dydx.abacus.protocols.TrackingProtocol, + '__doNotUseOrImplementIt' +>; export type FileLocation = Abacus.exchange.dydx.abacus.protocols.FileLocation; export type ThreadingType = Abacus.exchange.dydx.abacus.protocols.ThreadingType; diff --git a/src/constants/analytics.ts b/src/constants/analytics.ts index 121d6e5..a9b8ed2 100644 --- a/src/constants/analytics.ts +++ b/src/constants/analytics.ts @@ -71,6 +71,7 @@ export enum AnalyticsEvent { // Transfers TransferFaucet = 'TransferFaucet', + TransferFaucetConfirmed = 'TransferFaucetConfirmed', TransferDeposit = 'TransferDeposit', TransferWithdraw = 'TransferWithdraw', @@ -78,6 +79,7 @@ export enum AnalyticsEvent { TradeOrderTypeSelected = 'TradeOrderTypeSelected', TradePlaceOrder = 'TradePlaceOrder', TradePlaceOrderConfirmed = 'TradePlaceOrderConfirmed', + TradeCancelOrder = 'TradeCancelOrder', TradeCancelOrderConfirmed = 'TradeCancelOrderConfirmed', } @@ -133,6 +135,13 @@ export type AnalyticsEventData = : // Transfers T extends AnalyticsEvent.TransferFaucet ? {} + : T extends AnalyticsEvent.TransferFaucetConfirmed + ? { + /** roundtrip time between user placing an order and confirmation from indexer (client → validator → indexer → client) */ + roundtripMs: number; + /** URL/IP of node the order was sent to */ + validatorUrl: string; + } : T extends AnalyticsEvent.TransferDeposit ? {} : T extends AnalyticsEvent.TransferWithdraw @@ -151,13 +160,15 @@ export type AnalyticsEventData = /** roundtrip time between user placing an order and confirmation from indexer (client → validator → indexer → client) */ roundtripMs: number; /** URL/IP of node the order was sent to */ - validator: string; + validatorUrl: string; } + : T extends AnalyticsEvent.TradeCancelOrder + ? {} : T extends AnalyticsEvent.TradeCancelOrderConfirmed ? { /** roundtrip time between user canceling an order and confirmation from indexer (client → validator → indexer → client) */ roundtripMs: number; /** URL/IP of node the order was sent to */ - validator: string; + validatorUrl: string; } : never; diff --git a/src/constants/localization.ts b/src/constants/localization.ts index dc8e05a..f9d1d4b 100644 --- a/src/constants/localization.ts +++ b/src/constants/localization.ts @@ -88,19 +88,3 @@ export type TooltipStrings = { learnMoreLink?: string; }; }; - -export const ORDER_ERROR_CODE_MAP: { [key: number]: string } = { - 2000: STRING_KEYS['2000_FILL_OR_KILL_ORDER_COULD_NOT_BE_FULLY_FILLED'], - 2001: STRING_KEYS['2001_REDUCE_ONLY_WOULD_INCREASE_POSITION_SIZE'], - 2002: STRING_KEYS['2002_REDUCE_ONLY_WOULD_CHANGE_POSITION_SIDE'], - 2003: STRING_KEYS['2003_POST_ONLY_WOULD_CROSS_MAKER_ORDER'], - 3000: STRING_KEYS['3000_INVALID_ORDER_FLAGS'], - 3001: STRING_KEYS['3001_INVALID_STATEFUL_ORDER_GOOD_TIL_BLOCK_TIME'], - 3002: STRING_KEYS['3002_STATEFUL_ORDERS_CANNOT_REQUIRE_IMMEDIATE_EXECUTION'], - 3003: STRING_KEYS['3003_TIME_EXCEEDS_GOOD_TIL_BLOCK_TIME'], - 3004: STRING_KEYS['3004_GOOD_TIL_BLOCK_TIME_EXCEEDS_STATEFUL_ORDER_TIME_WINDOW'], - 3005: STRING_KEYS['3005_STATEFUL_ORDER_ALREADY_EXISTS'], - 3006: STRING_KEYS['3006_STATEFUL_ORDER_DOES_NOT_EXIST'], - 3007: STRING_KEYS['3007_STATEFUL_ORDER_COLLATERALIZATION_CHECK_FAILED'], - 3008: STRING_KEYS['3008_STATEFUL_ORDER_PREVIOUSLY_CANCELLED'], -}; diff --git a/src/constants/trade.ts b/src/constants/trade.ts index e85b7a8..a6fcd37 100644 --- a/src/constants/trade.ts +++ b/src/constants/trade.ts @@ -27,7 +27,7 @@ export enum PositionSide { Short = 'SHORT', } -export const UNCOMMITTED_ORDER_TIMEOUT = 10_000; +export const UNCOMMITTED_ORDER_TIMEOUT_MS = 10_000; export const ORDER_SIDE_STRINGS = { [OrderSide.BUY]: STRING_KEYS.BUY, diff --git a/src/hooks/useAccounts.tsx b/src/hooks/useAccounts.tsx index 9ac846b..5a6f494 100644 --- a/src/hooks/useAccounts.tsx +++ b/src/hooks/useAccounts.tsx @@ -8,10 +8,7 @@ import { OnboardingGuard, OnboardingState, type EvmDerivedAddresses } from '@/co import { LocalStorageKey, LOCAL_STORAGE_VERSIONS } from '@/constants/localStorage'; import { DydxAddress, EvmAddress, PrivateInformation } from '@/constants/wallets'; -import { - setOnboardingState, - setOnboardingGuard, -} from '@/state/account'; +import { setOnboardingState, setOnboardingGuard } from '@/state/account'; import abacusStateManager from '@/lib/abacus'; import { log } from '@/lib/telemetry'; @@ -222,9 +219,9 @@ const useAccountsContext = () => { // abacus // TODO: useAbacus({ dydxAddress }) useEffect(() => { - if (dydxAddress) abacusStateManager.setAccount(dydxAddress); + if (dydxAddress) abacusStateManager.setAccount(localDydxWallet); else abacusStateManager.attemptDisconnectAccount(); - }, [dydxAddress]); + }, [localDydxWallet]); // clear subaccounts when no dydxAddress is set useEffect(() => { diff --git a/src/hooks/useMarketsData.ts b/src/hooks/useMarketsData.ts index af6b545..b961ea8 100644 --- a/src/hooks/useMarketsData.ts +++ b/src/hooks/useMarketsData.ts @@ -29,7 +29,7 @@ export const useMarketsData = ( const markets = useMemo(() => { return Object.values(allPerpetualMarkets) - .filter(({ assetId }) => allAssets[assetId]) // Filter out markets with no asset information + .filter((market) => allAssets[market?.assetId]) // Filter out markets with no asset information .map((marketData) => ({ asset: allAssets[marketData.assetId], tickSizeDecimals: marketData.configs?.tickSizeDecimals, diff --git a/src/hooks/useOnLastOrderIndexed.ts b/src/hooks/useOnLastOrderIndexed.ts new file mode 100644 index 0000000..bdaece8 --- /dev/null +++ b/src/hooks/useOnLastOrderIndexed.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; + +import { getLatestOrderClientId } from '@/state/accountSelectors'; + +/** + * @description This hook will fire a callback when the latest order has been updated to a non-pending state from the Indexer. + * @param { callback: () => void } + */ +export const useOnLastOrderIndexed = ({ callback }: { callback: () => void }) => { + const [unIndexedClientId, setUnIndexedClientId] = useState(); + const latestOrderClientId = useSelector(getLatestOrderClientId); + + useEffect(() => { + if (unIndexedClientId) { + if (unIndexedClientId === latestOrderClientId) { + callback(); + setUnIndexedClientId(undefined); + } + } + }, [callback, latestOrderClientId, unIndexedClientId]); + + return { + setUnIndexedClientId, + }; +}; diff --git a/src/hooks/useSubaccount.tsx b/src/hooks/useSubaccount.tsx index 763991c..f4748ed 100644 --- a/src/hooks/useSubaccount.tsx +++ b/src/hooks/useSubaccount.tsx @@ -7,12 +7,7 @@ import type { EncodeObject, Coin } from '@cosmjs/proto-signing'; import { Method } from '@cosmjs/tendermint-rpc'; import { - LocalWallet, - OrderExecution, - OrderFlags, - OrderSide, - OrderTimeInForce, - OrderType, + type LocalWallet, SubaccountClient, DYDX_DENOM, USDC_DENOM, @@ -20,28 +15,20 @@ import { } from '@dydxprotocol/v4-client-js'; import type { - HumanReadableCancelOrderPayload, HumanReadablePlaceOrderPayload, + ParsingError, SubAccountHistoricalPNLs, } from '@/constants/abacus'; import { AMOUNT_RESERVED_FOR_GAS_USDC } from '@/constants/account'; import { AnalyticsEvent } from '@/constants/analytics'; -import { ORDER_ERROR_CODE_MAP } from '@/constants/localization'; import { QUANTUM_MULTIPLIER } from '@/constants/numbers'; -import { UNCOMMITTED_ORDER_TIMEOUT } from '@/constants/trade'; import { DydxAddress } from '@/constants/wallets'; -import { - addUncommittedOrderClientId, - removeUncommittedOrderClientId, - setSubaccount, - setHistoricalPnl, -} from '@/state/account'; +import { setSubaccount, setHistoricalPnl, removeUncommittedOrderClientId } from '@/state/account'; import abacusStateManager from '@/lib/abacus'; import { track } from '@/lib/analytics'; -import { StatefulOrderError } from '@/lib/errors'; import { MustBigNumber } from '@/lib/numbers'; import { log } from '@/lib/telemetry'; @@ -87,8 +74,6 @@ export const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: Lo transferFromSubaccountToAddress, transferNativeToken, simulateTransferNativeToken, - placeOrderForSubaccount, - cancelOrderForSubaccount, sendSquidWithdrawFromSubaccount, } = useMemo( () => ({ @@ -210,100 +195,6 @@ export const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: Lo GAS_PRICE_DYDX_DENOM, undefined ), - - placeOrderForSubaccount: async ({ - subaccount, - marketId, - type, - side, - price, - triggerPrice, - size, - clientId, - timeInForce, - goodTilTimeInSeconds, - execution, - postOnly, - reduceOnly, - }: { - subaccount: SubaccountClient; - marketId: string; - type: OrderType; - side: OrderSide; - price: number; - triggerPrice: Nullable; - size: number; - clientId: number; - timeInForce: OrderTimeInForce; - goodTilTimeInSeconds: number; - execution: OrderExecution; - postOnly: boolean; - reduceOnly: boolean; - }) => { - const startTimestamp = performance.now(); - - const result = await compositeClient?.placeOrder( - subaccount, - marketId, - type, - side, - price, - size, - clientId, - timeInForce, - goodTilTimeInSeconds, - execution, - postOnly, - reduceOnly, - triggerPrice ?? undefined - ); - - const endTimestamp = performance.now(); - - track(AnalyticsEvent.TradePlaceOrderConfirmed, { - roundtripMs: endTimestamp - startTimestamp, - validator: compositeClient!.validatorClient.config.restEndpoint, - }); - - return result; - }, - - cancelOrderForSubaccount: async ({ - subaccount, - clientId, - clobPairId, - orderFlags, - goodTilBlock, - goodTilBlockTime, - }: { - subaccount: SubaccountClient; - clientId: number; - orderFlags: OrderFlags; - clobPairId: number; - goodTilBlock?: number; - goodTilBlockTime?: number; - }) => { - const startTimestamp = performance.now(); - - const result = await compositeClient?.cancelOrder( - subaccount, - clientId, - orderFlags, - clobPairId, - goodTilBlock, - goodTilBlockTime - ); - - const endTimestamp = performance.now(); - - track(AnalyticsEvent.TradeCancelOrderConfirmed, { - roundtripMs: endTimestamp - startTimestamp, - validator: compositeClient!.validatorClient.config.restEndpoint, - }); - - return result; - }, - sendSquidWithdrawFromSubaccount: async ({ subaccountClient, amount, @@ -467,101 +358,41 @@ export const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: Lo onSuccess, }: { isClosePosition?: boolean; - onError?: (onErrorParams?: { errorStringKey?: string }) => void; - onSuccess?: () => void; + onError?: (onErrorParams?: { errorStringKey?: Nullable }) => void; + onSuccess?: (placeOrderPayload: Nullable) => void; }) => { - let orderParams: Nullable; + const callback = ( + success: boolean, + parsingError?: Nullable, + data?: Nullable + ) => { + if (success) { + onSuccess?.(data); + } else { + onError?.({ errorStringKey: parsingError?.stringKey }); - if (!subaccountClient) return; - - try { - orderParams = isClosePosition - ? abacusStateManager.closePositionPayload() - : abacusStateManager.placeOrderPayload(); - - if (!orderParams) { - throw new Error('Missing order params'); + if (data?.clientId !== undefined) { + dispatch(removeUncommittedOrderClientId(data.clientId)); + } } + }; - const { - marketId, - type, - side, - price, - triggerPrice, - size, - clientId, - timeInForce, - goodTilTimeInSeconds, - execution, - postOnly, - reduceOnly, - } = orderParams; + let placeOrderParams; - dispatch(addUncommittedOrderClientId(clientId)); - - // Remove uncommitted order after timeout if it hasn't already been removed - setTimeout(() => { - dispatch(removeUncommittedOrderClientId(clientId)); - }, UNCOMMITTED_ORDER_TIMEOUT); - - console.log('useSubaccount/placeOrder', { - ...orderParams, - }); - - const response = await placeOrderForSubaccount({ - subaccount: subaccountClient, - marketId, - type: type as OrderType, - side: side as OrderSide, - price, - triggerPrice, - size, - clientId, - timeInForce: timeInForce as OrderTimeInForce, - goodTilTimeInSeconds: goodTilTimeInSeconds ?? 0, - execution: execution as OrderExecution, - postOnly, - reduceOnly, - }); - - // Handle Stateful orders - if ((response as IndexedTx)?.code !== 0) { - throw new StatefulOrderError('Stateful order has failed to commit.', response); - } - - if (orderParams?.clientId) { - dispatch(removeUncommittedOrderClientId(orderParams.clientId)); - } - - if (response?.hash) { - console.log( - isClosePosition - ? 'useSubaccount/closePosition' - : 'useSubaccount/placeOrderForSubaccount', - { - txHash: Buffer.from(response.hash).toString('hex').toUpperCase(), - } - ); - } - - track(AnalyticsEvent.TradePlaceOrder, { - ...orderParams, - isClosePosition, - } as HumanReadablePlaceOrderPayload & { isClosePosition: boolean }); - onSuccess?.(); - } catch (error) { - const errorCode: number | undefined = error?.code; - const errorStringKey = errorCode ? ORDER_ERROR_CODE_MAP[errorCode] : undefined; - onError?.({ errorStringKey }); - - log('useSubaccount/placeOrder', error, { - orderParams, - isClosePosition, - }); + if (isClosePosition) { + placeOrderParams = abacusStateManager.closePosition(callback); + } else { + placeOrderParams = abacusStateManager.placeOrder(callback); } + + track(AnalyticsEvent.TradePlaceOrder, { + ...placeOrderParams, + isClosePosition, + } as HumanReadablePlaceOrderPayload & { isClosePosition: boolean }); + + return placeOrderParams; }, - [subaccountClient, placeOrderForSubaccount] + [subaccountClient] ); const closePosition = useCallback( @@ -569,8 +400,8 @@ export const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: Lo onError, onSuccess, }: { - onError: (onErrorParams?: { errorStringKey?: string }) => void; - onSuccess?: () => void; + onError: (onErrorParams?: { errorStringKey?: Nullable }) => void; + onSuccess?: (placeOrderPayload: Nullable) => void; }) => await placeOrder({ isClosePosition: true, onError, onSuccess }), [placeOrder] ); @@ -582,53 +413,21 @@ export const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: Lo onSuccess, }: { orderId: string; - onError?: ({ errorMsg }?: { errorMsg?: string }) => void; + onError?: ({ errorStringKey }?: { errorStringKey?: Nullable }) => void; onSuccess?: () => void; }) => { - let cancelOrderParams: Nullable; - - if (!subaccountClient) return; - - try { - cancelOrderParams = abacusStateManager.cancelOrderPayload(orderId); - - if (!cancelOrderParams) { - throw new Error('Missing cancel order params'); + const callback = (success: boolean, parsingError?: Nullable) => { + if (success) { + track(AnalyticsEvent.TradeCancelOrder); + onSuccess?.(); + } else { + onError?.({ errorStringKey: parsingError?.stringKey }); } + }; - const { clientId, clobPairId, goodTilBlock, goodTilBlockTime, orderFlags } = - cancelOrderParams; - - // Keep for debugging - console.log('useSubaccount/cancelOrder', cancelOrderParams); - - const response = await cancelOrderForSubaccount({ - subaccount: subaccountClient, - clientId, - orderFlags, - clobPairId, - goodTilBlock: goodTilBlock || undefined, - goodTilBlockTime: goodTilBlockTime || undefined, - }); - - // Handle Stateful orders - if ((response as IndexedTx)?.code !== 0) { - throw new StatefulOrderError('Stateful cancel has failed to commit.', response); - } - - if (response?.hash) { - console.log('useSubaccount/cancelOrderForSubaccount', { - txHash: Buffer.from(response.hash).toString('hex').toUpperCase(), - }); - } - - onSuccess?.(); - } catch (error) { - onError?.(); - log('useSubaccount/cancelOrder', error, cancelOrderParams); - } + abacusStateManager.cancelOrder(orderId, callback); }, - [subaccountClient, cancelOrderForSubaccount] + [subaccountClient] ); return { diff --git a/src/lib/abacus/analytics.ts b/src/lib/abacus/analytics.ts new file mode 100644 index 0000000..3b27080 --- /dev/null +++ b/src/lib/abacus/analytics.ts @@ -0,0 +1,18 @@ +import type { AbacusTrackingProtocol, Nullable } from '@/constants/abacus'; +import type { AnalyticsEvent } from '@/constants/analytics'; + +import { track } from '../analytics'; +import { log as telemetryLog } from '../telemetry'; + +class AbacusAnalytics implements AbacusTrackingProtocol { + log(event: string, data: Nullable) { + try { + const parsedData = data ? JSON.parse(data) : {}; + track(event as AnalyticsEvent, parsedData); + } catch (error) { + telemetryLog('AbacusAnalytics/log', error); + } + } +} + +export default AbacusAnalytics; diff --git a/src/lib/abacus/dydxChainTransactions.ts b/src/lib/abacus/dydxChainTransactions.ts index 5efd9a1..a616899 100644 --- a/src/lib/abacus/dydxChainTransactions.ts +++ b/src/lib/abacus/dydxChainTransactions.ts @@ -1,30 +1,46 @@ -import Abacus, { Nullable } from '@dydxprotocol/v4-abacus'; +import Abacus, { type Nullable } from '@dydxprotocol/v4-abacus'; import Long from 'long'; +import type { IndexedTx } from '@cosmjs/stargate'; import { CompositeClient, IndexerConfig, + type LocalWallet, Network, NetworkOptimizer, + SubaccountClient, ValidatorConfig, + OrderType, + OrderSide, + OrderTimeInForce, + OrderExecution, } from '@dydxprotocol/v4-client-js'; import { type AbacusDYDXChainTransactionsProtocol, QueryType, type QueryTypes, + TransactionType, type TransactionTypes, + type HumanReadablePlaceOrderPayload, + type HumanReadableCancelOrderPayload, } from '@/constants/abacus'; + import { DialogTypes } from '@/constants/dialogs'; +import { UNCOMMITTED_ORDER_TIMEOUT_MS } from '@/constants/trade'; import { RootStore } from '@/state/_store'; +import { addUncommittedOrderClientId, removeUncommittedOrderClientId } from '@/state/account'; import { openDialog } from '@/state/dialogs'; +import { StatefulOrderError } from '../errors'; +import { bytesToBigInt } from '../numbers'; import { log } from '../telemetry'; class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol { private compositeClient: CompositeClient | undefined; private store: RootStore | undefined; + private localWallet: LocalWallet | undefined; constructor() { this.compositeClient = undefined; @@ -35,6 +51,10 @@ class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol { this.store = store; } + setLocalWallet(localWallet: LocalWallet) { + this.localWallet = localWallet; + } + async connectNetwork( indexerUrl: string, indexerSocketUrl: string, @@ -93,6 +113,10 @@ class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol { return x.toString() as T; } + if (x instanceof Uint8Array) { + return bytesToBigInt(x).toString() as T; + } + if (typeof x === 'object') { const parsedObj: { [key: string]: any } = {}; for (const key in x) { @@ -106,13 +130,138 @@ class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol { throw new Error(`Unsupported data type: ${typeof x}`); } + async placeOrderTransaction(params: HumanReadablePlaceOrderPayload): Promise { + if (!this.compositeClient || !this.localWallet) + throw new Error('Missing compositeClient or localWallet'); + + try { + const { + subaccountNumber, + marketId, + type, + side, + price, + size, + clientId, + timeInForce, + goodTilTimeInSeconds, + execution, + postOnly, + reduceOnly, + triggerPrice, + } = params || {}; + + // Observe uncommitted order + this.store?.dispatch(addUncommittedOrderClientId(clientId)); + + setTimeout(() => { + this.store?.dispatch(removeUncommittedOrderClientId(clientId)); + }, UNCOMMITTED_ORDER_TIMEOUT_MS); + + // Place order + const tx = await this.compositeClient?.placeOrder( + new SubaccountClient(this.localWallet, subaccountNumber), + marketId, + type as OrderType, + side as OrderSide, + price, + size, + clientId, + timeInForce as OrderTimeInForce, + goodTilTimeInSeconds ?? 0, + execution as OrderExecution, + postOnly, + reduceOnly, + triggerPrice ?? undefined + ); + + // Handle stateful orders + if ((tx as IndexedTx)?.code !== 0) { + throw new StatefulOrderError('Stateful order has failed to commit.', tx); + } + + const parsedTx = this.parseToPrimitives(tx); + const hash = parsedTx?.hash; + + if (import.meta.env.MODE === 'production') { + console.log(`https://testnet.mintscan.io/dydx-testnet/txs/${hash}`); + } else console.log(`txHash: ${hash}`); + + return JSON.stringify(parsedTx); + } catch (error) { + if (error?.name !== 'BroadcastError') { + log('DydxChainTransactions/placeOrderTransaction', error); + } + + return JSON.stringify({ + error, + }); + } + } + + async cancelOrderTransaction(params: HumanReadableCancelOrderPayload): Promise { + if (!this.compositeClient || !this.localWallet) { + throw new Error('Missing compositeClient or localWallet'); + } + + const { subaccountNumber, clientId, orderFlags, clobPairId, goodTilBlock, goodTilBlockTime } = + params ?? {}; + + try { + const tx = await this.compositeClient?.cancelOrder( + new SubaccountClient(this.localWallet, subaccountNumber), + clientId, + orderFlags, + clobPairId, + goodTilBlock ?? undefined, + goodTilBlockTime ?? undefined + ); + + const parsedTx = this.parseToPrimitives(tx); + + return JSON.stringify(parsedTx); + } catch (error) { + log('DydxChainTransactions/cancelOrderTransaction', error); + + return JSON.stringify({ + error, + }); + } + } + async transaction( type: TransactionTypes, paramsInJson: Abacus.Nullable, callback: (p0: Abacus.Nullable) => void ): Promise { - // To be implemented - return; + try { + const params = paramsInJson ? JSON.parse(paramsInJson) : undefined; + + switch (type) { + case TransactionType.PlaceOrder: { + const result = await this.placeOrderTransaction(params); + callback(result); + break; + } + case TransactionType.CancelOrder: { + const result = await this.cancelOrderTransaction(params); + callback(result); + break; + } + default: { + break; + } + } + } catch (error) { + try { + const serializedError = JSON.stringify(error); + callback(serializedError); + } catch (parseError) { + log('DydxChainTransactions/transaction', parseError); + } + + log('DydxChainTransactions/transaction', error); + } } async get( @@ -136,6 +285,12 @@ class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol { ); callback(JSON.stringify({ url: optimalNode })); break; + case QueryType.EquityTiers: + const equityTiers = + await this.compositeClient?.validatorClient.get.getEquityTierLimitConfiguration(); + const parsedEquityTiers = this.parseToPrimitives(equityTiers); + callback(JSON.stringify(parsedEquityTiers)); + break; case QueryType.FeeTiers: const feeTiers = await this.compositeClient?.validatorClient.get.getFeeTiers(); const parsedFeeTiers = this.parseToPrimitives(feeTiers); diff --git a/src/lib/abacus/index.ts b/src/lib/abacus/index.ts index 6c1793e..f0d8b66 100644 --- a/src/lib/abacus/index.ts +++ b/src/lib/abacus/index.ts @@ -1,3 +1,5 @@ +import type { LocalWallet } from '@dydxprotocol/v4-client-js'; + import type { ClosePositionInputFields, Nullable, @@ -6,6 +8,7 @@ import type { TradeInputFields, TransferInputFields, HistoricalPnlPeriods, + ParsingError, } from '@/constants/abacus'; import { @@ -30,6 +33,7 @@ import { getInputTradeOptions } from '@/state/inputsSelectors'; import { getTransferInputs } from '@/state/inputsSelectors'; import AbacusRest from './rest'; +import AbacusAnalytics from './analytics'; import AbacusWebsocket from './websocket'; import AbacusChainTransaction from './dydxChainTransactions'; import AbacusStateNotifier from './stateNotification'; @@ -45,23 +49,25 @@ class AbacusStateManager { stateManager: InstanceType; websocket: AbacusWebsocket; stateNotifier: AbacusStateNotifier; + analytics: AbacusAnalytics; abacusFormatter: AbacusFormatter; - chainTransactionClient: AbacusChainTransaction; + chainTransactions: AbacusChainTransaction; constructor() { this.store = undefined; this.currentMarket = undefined; this.stateNotifier = new AbacusStateNotifier(); + this.analytics = new AbacusAnalytics(); this.websocket = new AbacusWebsocket(); this.abacusFormatter = new AbacusFormatter(); - this.chainTransactionClient = new AbacusChainTransaction(); + this.chainTransactions = new AbacusChainTransaction(); const ioImplementations = new IOImplementations( // @ts-ignore new AbacusRest(), this.websocket, - this.chainTransactionClient, - null, + this.chainTransactions, + this.analytics, new AbacusThreading(), new CoroutineTimer(), new AbacusFileSystem() @@ -157,11 +163,14 @@ class AbacusStateManager { setStore = (store: RootStore) => { this.store = store; this.stateNotifier.setStore(store); - this.chainTransactionClient.setStore(store); + this.chainTransactions.setStore(store); }; - setAccount = (walletAddress: string) => { - this.stateManager.accountAddress = walletAddress; + setAccount = (localWallet?: LocalWallet) => { + if (localWallet) { + this.stateManager.accountAddress = localWallet.address; + this.chainTransactions.setLocalWallet(localWallet); + } }; setTransfersSourceAddress = (evmAddress: string) => { @@ -219,16 +228,34 @@ class AbacusStateManager { this.stateManager.transferStatus(hash, fromChainId, toChainId); }; + // ------ Transactions ------ // + + placeOrder = ( + callback: ( + success: boolean, + parsingError: Nullable, + data: Nullable + ) => void + ): Nullable => this.stateManager.commitPlaceOrder(callback); + + closePosition = ( + callback: ( + success: boolean, + parsingError: Nullable, + data: Nullable + ) => void + ): Nullable => this.stateManager.commitClosePosition(callback); + + cancelOrder = ( + orderId: string, + callback: ( + success: boolean, + parsingError: Nullable, + data: Nullable + ) => void + ) => this.stateManager.cancelOrder(orderId, callback); + // ------ Utils ------ // - placeOrderPayload = (): Nullable => - this.stateManager.placeOrderPayload(); - - closePositionPayload = (): Nullable => - this.stateManager.closePositionPayload(); - - cancelOrderPayload = (orderId: string): Nullable => - this.stateManager.cancelOrderPayload(orderId); - getHistoricalPnlPeriod = (): Nullable => this.stateManager.historicalPnlPeriod; diff --git a/src/lib/abacus/stateNotification.ts b/src/lib/abacus/stateNotification.ts index 6bd92b1..3c0262f 100644 --- a/src/lib/abacus/stateNotification.ts +++ b/src/lib/abacus/stateNotification.ts @@ -21,6 +21,7 @@ import { setFills, setFundingPayments, setHistoricalPnl, + setLatestOrder, setSubaccount, setTransfers, setWallet, @@ -155,7 +156,9 @@ class AbacusStateNotifier implements AbacusStateNotificationProtocol { } } - lastOrderChanged(order: SubaccountOrder) {} + lastOrderChanged(order: SubaccountOrder) { + this.store?.dispatch(setLatestOrder(order)); + } errorsEmitted(errors: ParsingErrors) { console.error('parse errors', errors.toArray()); diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 151f4ef..7904c78 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -5,10 +5,15 @@ import type { AnalyticsEventData, } from '@/constants/analytics'; +const DEBUG_ANALYTICS = false; + export const identify = ( property: T, propertyValue: AnalyticsUserPropertyValue ) => { + if (DEBUG_ANALYTICS) { + console.log(`[Analytics:Identify] ${property}`, propertyValue); + } const customEvent = new CustomEvent('dydx:identify', { detail: { property, propertyValue }, }); @@ -20,6 +25,9 @@ export const track = ( eventType: T, eventData?: AnalyticsEventData ) => { + if (DEBUG_ANALYTICS) { + console.log(`[Analytics] ${eventType}`, eventData); + } const customEvent = new CustomEvent('dydx:track', { detail: { eventType, eventData }, }); diff --git a/src/lib/numbers.ts b/src/lib/numbers.ts index d7527b9..986a4c1 100644 --- a/src/lib/numbers.ts +++ b/src/lib/numbers.ts @@ -66,3 +66,17 @@ export const getSeparator = ({ Intl.NumberFormat(browserLanguage) .formatToParts(1000.1) .find?.((part) => part.type === separatorType)?.value; + +/** + * Converts a byte array (representing an arbitrary-size signed integer) into a bigint. + * @param u Array of bytes represented as a Uint8Array. + */ +export function bytesToBigInt(u: Uint8Array): bigint { + if (u.length <= 1) { + return BigInt(0); + } + const negated: boolean = (u[0] & 1) === 1; + const hex: string = Buffer.from(u.slice(1)).toString('hex'); + const abs: bigint = BigInt(`0x${hex}`); + return negated ? -abs : abs; +} diff --git a/src/lib/orders.ts b/src/lib/orders.ts index 4d76c3c..f2cf190 100644 --- a/src/lib/orders.ts +++ b/src/lib/orders.ts @@ -58,6 +58,12 @@ export const getStatusIconInfo = ({ statusIconColor: `var(--color-negative)`, }; } + case AbacusOrderStatus.untriggered: { + return { + statusIcon: IconName.OrderUntriggered, + statusIconColor: `var(--color-text-2)`, + }; + } case AbacusOrderStatus.pending: default: { return { diff --git a/src/pages/trade/HorizontalPanel.tsx b/src/pages/trade/HorizontalPanel.tsx index 6473904..163b942 100644 --- a/src/pages/trade/HorizontalPanel.tsx +++ b/src/pages/trade/HorizontalPanel.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { shallowEqual, useSelector } from 'react-redux'; import styled, { type AnyStyledComponent } from 'styled-components'; +import { AbacusOrderStatus } from '@/constants/abacus'; import { STRING_KEYS } from '@/constants/localization'; import { useBreakpoints, useStringGetter } from '@/hooks'; @@ -18,13 +19,16 @@ import { FillsTable, FillsTableColumnKey } from '@/views/tables/FillsTable'; import { OrdersTable, OrdersTableColumnKey } from '@/views/tables/OrdersTable'; import { PositionsTable, PositionsTableColumnKey } from '@/views/tables/PositionsTable'; -import { calculateIsAccountViewOnly } from '@/state/accountCalculators'; - import { calculateHasUncommittedOrders, + calculateIsAccountViewOnly, +} from '@/state/accountCalculators'; + +import { getCurrentMarketTradeInfoNumbers, getHasUnseenFillUpdates, getHasUnseenOrderUpdates, + getLatestOrderStatus, getTradeInfoNumbers, } from '@/state/accountSelectors'; @@ -69,8 +73,7 @@ export const HorizontalPanel = ({ isOpen = true, setIsOpen }: ElementProps) => { const hasUnseenOrderUpdates = useSelector(getHasUnseenOrderUpdates); const hasUnseenFillUpdates = useSelector(getHasUnseenFillUpdates); const isAccountViewOnly = useSelector(calculateIsAccountViewOnly); - const hasUncommittedOrders = useSelector(calculateHasUncommittedOrders); - + const isWaitingForOrderToIndex = useSelector(calculateHasUncommittedOrders); const showCurrentMarket = isTablet || view === PanelView.CurrentMarket; const fillsTagNumber = shortenNumberForDisplay(showCurrentMarket ? numFills : numTotalFills); @@ -118,7 +121,7 @@ export const HorizontalPanel = ({ isOpen = true, setIsOpen }: ElementProps) => { value: InfoSection.Orders, label: stringGetter({ key: STRING_KEYS.ORDERS }), - slotRight: hasUncommittedOrders ? ( + slotRight: isWaitingForOrderToIndex ? ( ) : ( ordersTagNumber && ( diff --git a/src/state/_store.ts b/src/state/_store.ts index fd57b66..28a0b4e 100644 --- a/src/state/_store.ts +++ b/src/state/_store.ts @@ -4,7 +4,6 @@ import abacusStateManager from '@/lib/abacus'; import appMiddleware from './appMiddleware'; import localizationMiddleware from './localizationMiddleware'; import routerMiddleware from './routerMiddleware'; -import tradeMiddleware from './tradeMiddleware'; import { accountSlice } from './account'; import { appSlice } from './app'; @@ -40,7 +39,6 @@ export const store = configureStore({ appMiddleware, localizationMiddleware, routerMiddleware, - tradeMiddleware, ], devTools: process.env.NODE_ENV !== 'production', diff --git a/src/state/account.ts b/src/state/account.ts index 8c50aa1..f234a16 100644 --- a/src/state/account.ts +++ b/src/state/account.ts @@ -24,13 +24,14 @@ export type AccountState = { fundingPayments?: SubaccountFundingPayments; transfers?: SubaccountTransfers; clearedOrderIds?: string[]; - uncommittedOrderClientIds?: number[]; hasUnseenFillUpdates: boolean; hasUnseenOrderUpdates: boolean; historicalPnl?: SubAccountHistoricalPNLs; + latestOrder?: Nullable; onboardingGuards: Record; onboardingState: OnboardingState; subaccount?: Nullable; + uncommittedOrderClientIds: number[]; wallet?: Nullable; walletType?: WalletType; historicalPnlPeriod?: HistoricalPnlPeriods; @@ -41,10 +42,10 @@ const initialState: AccountState = { fundingPayments: undefined, transfers: undefined, clearedOrderIds: undefined, - uncommittedOrderClientIds: undefined, hasUnseenFillUpdates: false, hasUnseenOrderUpdates: false, historicalPnl: undefined, + latestOrder: undefined, onboardingGuards: { [OnboardingGuard.hasAcknowledgedTerms]: Boolean( getLocalStorage({ @@ -56,6 +57,7 @@ const initialState: AccountState = { }, onboardingState: OnboardingState.Disconnected, subaccount: undefined, + uncommittedOrderClientIds: [], wallet: undefined, walletType: getLocalStorage({ key: LocalStorageKey.OnboardingSelectedWalletType, @@ -86,6 +88,16 @@ export const accountSlice = createSlice({ setTransfers: (state, action: PayloadAction) => { state.transfers = action.payload; }, + setLatestOrder: (state, action: PayloadAction>) => { + const { clientId } = action.payload ?? {}; + state.latestOrder = action.payload; + + if (clientId) { + state.uncommittedOrderClientIds = state.uncommittedOrderClientIds.filter( + (id) => id !== clientId + ); + } + }, clearOrder: (state, action: PayloadAction) => ({ ...state, clearedOrderIds: [...(state.clearedOrderIds || []), action.payload], @@ -131,22 +143,20 @@ export const accountSlice = createSlice({ ...state, wallet: action.payload, }), - addUncommittedOrderClientId: (state, action: PayloadAction) => { - state.uncommittedOrderClientIds = state.uncommittedOrderClientIds - ? [...state.uncommittedOrderClientIds, action.payload] - : [action.payload]; - }, - removeUncommittedOrderClientId: (state, action: PayloadAction) => { - state.uncommittedOrderClientIds = state.uncommittedOrderClientIds?.filter( - (clientId) => clientId !== action.payload - ); - }, viewedFills: (state) => { state.hasUnseenFillUpdates = false; }, viewedOrders: (state) => { state.hasUnseenOrderUpdates = false; }, + addUncommittedOrderClientId: (state, action: PayloadAction) => { + state.uncommittedOrderClientIds.push(action.payload); + }, + removeUncommittedOrderClientId: (state, action: PayloadAction) => { + state.uncommittedOrderClientIds = state.uncommittedOrderClientIds.filter( + (id) => id !== action.payload + ); + }, }, }); @@ -154,14 +164,15 @@ export const { setFills, setFundingPayments, setTransfers, + setLatestOrder, clearOrder, setOnboardingGuard, setOnboardingState, setHistoricalPnl, setSubaccount, setWallet, - addUncommittedOrderClientId, - removeUncommittedOrderClientId, viewedFills, viewedOrders, + addUncommittedOrderClientId, + removeUncommittedOrderClientId, } = accountSlice.actions; diff --git a/src/state/accountCalculators.ts b/src/state/accountCalculators.ts index 10f0c48..d068cde 100644 --- a/src/state/accountCalculators.ts +++ b/src/state/accountCalculators.ts @@ -9,6 +9,7 @@ import { getOnboardingGuards, getOnboardingState, getSubaccountId, + getUncommittedOrderClientIds, } from '@/state/accountSelectors'; import { getSelectedNetwork } from '@/state/appSelectors'; @@ -69,6 +70,11 @@ export const calculateHasOpenPositions = createSelector( (openPositions?: SubaccountPosition[]) => (openPositions?.length || 0) > 0 ); +export const calculateHasUncommittedOrders = createSelector( + [getUncommittedOrderClientIds], + (uncommittedOrderClientIds: number[]) => uncommittedOrderClientIds.length > 0 +); + /** * @description calculate whether the client is loading info. * (account is connected but subaccountId is till missing) diff --git a/src/state/accountSelectors.ts b/src/state/accountSelectors.ts index bd1a212..a03faeb 100644 --- a/src/state/accountSelectors.ts +++ b/src/state/accountSelectors.ts @@ -78,6 +78,12 @@ export const getCurrentMarketPositionData = (state: RootState) => { export const getSubaccountOrders = (state: RootState) => state.account.subaccount?.orders?.toArray(); +/** + * @param state + * @returns latestOrder of the currently connected subaccount throughout this session + */ +export const getLatestOrder = (state: RootState) => state.account?.latestOrder; + /** * @param state * @returns list of order ids that user has cleared and should be hidden @@ -115,7 +121,26 @@ export const getSubaccountOpenOrdersBySideAndPrice = createSelector( ); /** - * @param state + * @returns the clientId of the latest order + */ +export const getLatestOrderClientId = createSelector([getLatestOrder], (order) => order?.clientId); + +/** + * @returns the rawValue status of the latest order + */ +export const getLatestOrderStatus = createSelector( + [getLatestOrder], + (order) => order?.status.rawValue +); + +/** + * @returns a list of clientIds belonging to uncommmited orders + */ +export const getUncommittedOrderClientIds = (state: RootState) => + state.account.uncommittedOrderClientIds; + +/** + * @param orderId * @returns order details with the given orderId */ export const getOrderDetails = (orderId: string) => @@ -283,21 +308,6 @@ export const getIsAccountConnected = (state: RootState) => */ export const getOnboardingGuards = (state: RootState) => state.account.onboardingGuards; -/** - * @param state - * @returns a boolean indicating whether the user has uncommitted orders - */ -export const calculateHasUncommittedOrders = (state: RootState) => { - return !!state.account.uncommittedOrderClientIds?.length; -}; - -/** - * @param state - * @returns List of uncommitted order clientIds - */ -export const getUncommittedOrderClientIds = (state: RootState) => - state.account.uncommittedOrderClientIds; - /** * @param state * @returns Whether there are unseen fill updates diff --git a/src/state/tradeMiddleware.ts b/src/state/tradeMiddleware.ts deleted file mode 100644 index 30c0d3c..0000000 --- a/src/state/tradeMiddleware.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { PayloadAction } from '@reduxjs/toolkit'; - -import type { SubaccountOrder } from '@/constants/abacus'; - -import { removeUncommittedOrderClientId, setSubaccount } from '@/state/account'; - -import { getSubaccountOrders, getUncommittedOrderClientIds } from '@/state/accountSelectors'; - -export default (store: any) => (next: any) => async (action: PayloadAction) => { - const { type, payload } = action; - const state = store.getState(); - next(action); - - switch (type) { - case setSubaccount.type: { - const updatedOrders = payload?.orders?.toArray() || []; - const orders = getSubaccountOrders(state); - const uncommittedOrderClientIds = getUncommittedOrderClientIds(state); - - if (uncommittedOrderClientIds?.length && updatedOrders?.length !== orders?.length) { - const updatedOrdersClientIds = Object.fromEntries( - updatedOrders.map((order: SubaccountOrder) => [order.clientId, order]) - ); - - const receivedClientId = uncommittedOrderClientIds.find( - (clientId) => updatedOrdersClientIds[clientId] - ); - - if (receivedClientId) { - store.dispatch(removeUncommittedOrderClientId(receivedClientId)); - } - } - break; - } - default: { - break; - } - } -}; diff --git a/src/views/AccountInfo/AccountInfoConnectedState.tsx b/src/views/AccountInfo/AccountInfoConnectedState.tsx index 0cc7df9..f639724 100644 --- a/src/views/AccountInfo/AccountInfoConnectedState.tsx +++ b/src/views/AccountInfo/AccountInfoConnectedState.tsx @@ -62,8 +62,10 @@ export const AccountInfoConnectedState = () => { const { buyingPower, equity, marginUsage, leverage } = subAccount || {}; const hasDiff = - marginUsage?.postOrder != null && - !MustBigNumber(marginUsage?.postOrder).eq(MustBigNumber(marginUsage?.current)); + (marginUsage?.postOrder !== null && + !MustBigNumber(marginUsage?.postOrder).eq(MustBigNumber(marginUsage?.current))) || + (buyingPower?.postOrder !== null && + !MustBigNumber(buyingPower?.postOrder).eq(MustBigNumber(buyingPower?.current))); const showHeader = !hasDiff && !isTablet; diff --git a/src/views/MarketLinks.tsx b/src/views/MarketLinks.tsx index 2e4a43c..be38805 100644 --- a/src/views/MarketLinks.tsx +++ b/src/views/MarketLinks.tsx @@ -15,14 +15,17 @@ export const MarketLinks = () => { const linkItems = [ { + key: 'coinmarketcap', href: coinMarketCapsLink, icon: IconName.CoinMarketCap, }, { + key: 'whitepaper', href: whitepaperLink, icon: IconName.Whitepaper, }, { + key: 'project-website', href: websiteLink, icon: IconName.Website, }, @@ -31,8 +34,8 @@ export const MarketLinks = () => { return ( {linkItems.map( - ({ href, icon }) => - href && + ({ key, href, icon }) => + href && )} ); diff --git a/src/views/dialogs/DetailsDialog/OrderDetailsDialog.tsx b/src/views/dialogs/DetailsDialog/OrderDetailsDialog.tsx index ce09538..50b16a2 100644 --- a/src/views/dialogs/DetailsDialog/OrderDetailsDialog.tsx +++ b/src/views/dialogs/DetailsDialog/OrderDetailsDialog.tsx @@ -7,7 +7,7 @@ import { layoutMixins } from '@/styles/layoutMixins'; import { AbacusOrderStatus, AbacusOrderTypes, type Nullable } from '@/constants/abacus'; import { ButtonAction } from '@/constants/buttons'; -import { STRING_KEYS } from '@/constants/localization'; +import { STRING_KEYS, type StringKey } from '@/constants/localization'; import { AssetIcon } from '@/components/AssetIcon'; import { Button } from '@/components/Button'; @@ -47,7 +47,7 @@ export const OrderDetailsDialog = ({ orderId, setIsOpen }: ElementProps) => { const { cancelOrder } = useSubaccount(); const { - asset = {}, + asset, cancelReason, createdAtMilliseconds, expiresAtMilliseconds, @@ -57,7 +57,7 @@ export const OrderDetailsDialog = ({ orderId, setIsOpen }: ElementProps) => { price, reduceOnly, totalFilled, - resources = {}, + resources, size, status, stepSizeDecimals, @@ -121,7 +121,9 @@ export const OrderDetailsDialog = ({ orderId, setIsOpen }: ElementProps) => { { key: 'cancel-reason', label: stringGetter({ key: STRING_KEYS.CANCEL_REASON }), - value: cancelReason ? stringGetter({ key: STRING_KEYS[cancelReason] }) : undefined, + value: cancelReason + ? stringGetter({ key: STRING_KEYS[cancelReason as StringKey] }) + : undefined, }, { key: 'amount', @@ -182,8 +184,7 @@ export const OrderDetailsDialog = ({ orderId, setIsOpen }: ElementProps) => { const onCancelClick = async () => { setIsPlacingCancel(true); - await cancelOrder({ orderId }); - setIsPlacingCancel(false); + await cancelOrder({ orderId, onError: () => setIsPlacingCancel(false) }); }; const onClearClick = () => { diff --git a/src/views/dialogs/TradeDialog.tsx b/src/views/dialogs/TradeDialog.tsx index c67d46a..90e3c09 100644 --- a/src/views/dialogs/TradeDialog.tsx +++ b/src/views/dialogs/TradeDialog.tsx @@ -17,7 +17,7 @@ import { Ring } from '@/components/Ring'; import { ToggleGroup } from '@/components/ToggleGroup'; import { getCurrentMarketAssetData } from '@/state/assetsSelectors'; -import { getInputTradeData } from '@/state/inputsSelectors'; +import { getInputTradeData, getInputTradeOptions } from '@/state/inputsSelectors'; import abacusStateManager from '@/lib/abacus'; import { getSelectedTradeType } from '@/lib/tradeData'; @@ -35,6 +35,15 @@ export const TradeDialog = ({ isOpen, setIsOpen, slotTrigger }: ElementProps) => const currentTradeData = useSelector(getInputTradeData, shallowEqual); const { type } = currentTradeData || {}; const selectedTradeType = getSelectedTradeType(type); + const { typeOptions } = useSelector(getInputTradeOptions, shallowEqual) ?? {}; + + const allTradeTypeItems = typeOptions?.toArray()?.map(({ type, stringKey }) => ({ + value: type, + label: stringGetter({ + key: stringKey, + }), + slotBefore: , + })); const [currentStep, setCurrentStep] = useState( MobilePlaceOrderSteps.EditOrder @@ -62,11 +71,7 @@ export const TradeDialog = ({ isOpen, setIsOpen, slotTrigger }: ElementProps) => [MobilePlaceOrderSteps.EditOrder]: { title: ( ({ - value: tradeType, - label: stringGetter({ key: TRADE_TYPE_STRINGS[tradeType].tradeTypeKey }), - slotBefore: , - }))} + items={allTradeTypeItems} value={selectedTradeType} onValueChange={(tradeType: TradeTypes) => onTradeTypeChange(tradeType || selectedTradeType) @@ -126,6 +131,8 @@ Styled.Dialog = styled(Dialog)<{ currentStep: MobilePlaceOrderSteps }>` `; Styled.ToggleGroup = styled(ToggleGroup)` + overflow-x: auto; + button[data-state='off'] { gap: 0; diff --git a/src/views/forms/ClosePositionForm.tsx b/src/views/forms/ClosePositionForm.tsx index 8db9287..262e82a 100644 --- a/src/views/forms/ClosePositionForm.tsx +++ b/src/views/forms/ClosePositionForm.tsx @@ -1,14 +1,19 @@ -import { type FormEvent, useEffect, useState } from 'react'; +import { type FormEvent, useCallback, useEffect, useState } from 'react'; import styled, { type AnyStyledComponent } from 'styled-components'; import { shallowEqual, useDispatch, useSelector } from 'react-redux'; -import { ClosePositionInputField } from '@/constants/abacus'; +import { + ClosePositionInputField, + type HumanReadablePlaceOrderPayload, + type Nullable, +} from '@/constants/abacus'; import { AlertType } from '@/constants/alerts'; import { ButtonAction, ButtonShape, ButtonSize, ButtonType } from '@/constants/buttons'; import { TOKEN_DECIMALS } from '@/constants/numbers'; import { STRING_KEYS } from '@/constants/localization'; import { MobilePlaceOrderSteps } from '@/constants/trade'; import { useBreakpoints, useIsFirstRender, useStringGetter, useSubaccount } from '@/hooks'; +import { useOnLastOrderIndexed } from '@/hooks/useOnLastOrderIndexed'; import { breakpoints } from '@/styles'; import { layoutMixins } from '@/styles/layoutMixins'; @@ -27,10 +32,7 @@ import { Orderbook, orderbookMixins, type OrderbookScrollBehavior } from '@/view import { PositionPreview } from '@/views/forms/TradeForm/PositionPreview'; -import { - calculateHasUncommittedOrders, - getCurrentMarketPositionData, -} from '@/state/accountSelectors'; +import { getCurrentMarketPositionData } from '@/state/accountSelectors'; import { getCurrentMarketAssetData } from '@/state/assetsSelectors'; import { getInputClosePositionData } from '@/state/inputsSelectors'; @@ -82,9 +84,7 @@ export const ClosePositionForm = ({ const { stepSizeDecimals } = useSelector(getCurrentMarketConfig, shallowEqual) || {}; const { size: sizeData, summary } = useSelector(getInputClosePositionData, shallowEqual) || {}; const { size, percent } = sizeData || {}; - const hasUncommittedOrders = useSelector(calculateHasUncommittedOrders); const currentInput = useSelector(getCurrentInput); - const currentPositionData = useSelector(getCurrentMarketPositionData, shallowEqual); const { size: currentPositionSize } = currentPositionData || {}; const { current: currentSize } = currentPositionSize || {}; @@ -106,17 +106,22 @@ export const ClosePositionForm = ({ } }, [currentInput, market, currentStep]); - useEffect(() => { - // close has been placed - if (!isFirstRender && !hasUncommittedOrders) { + const onLastOrderIndexed = useCallback(() => { + if (!isFirstRender) { abacusStateManager.clearClosePositionInputValues({ shouldFocusOnTradeInput: true }); onClosePositionSuccess?.(); if (currentStep === MobilePlaceOrderSteps.PlacingOrder) { setCurrentStep?.(MobilePlaceOrderSteps.Confirmation); } + + setIsClosingPosition(false); } - }, [hasUncommittedOrders]); + }, [currentStep, isFirstRender]); + + const { setUnIndexedClientId } = useOnLastOrderIndexed({ + callback: onLastOrderIndexed, + }); const onAmountInput = ({ floatValue }: { floatValue?: number }) => { if (currentSize == null) return; @@ -165,14 +170,16 @@ export const ClosePositionForm = ({ setIsClosingPosition(true); await closePosition({ - onError: (errorParams?: { errorStringKey?: string }) => { + onError: (errorParams?: { errorStringKey?: Nullable }) => { setClosePositionError( stringGetter({ key: errorParams?.errorStringKey || STRING_KEYS.SOMETHING_WENT_WRONG }) ); + setIsClosingPosition(false); + }, + onSuccess: (placeOrderPayload: Nullable) => { + setUnIndexedClientId(placeOrderPayload?.clientId); }, }); - - setIsClosingPosition(false); }; const alertMessage = closePositionError && ( diff --git a/src/views/forms/TradeForm.tsx b/src/views/forms/TradeForm.tsx index 4802ccf..e67f7c0 100644 --- a/src/views/forms/TradeForm.tsx +++ b/src/views/forms/TradeForm.tsx @@ -1,4 +1,4 @@ -import { type FormEvent, useState, useEffect, Ref } from 'react'; +import { type FormEvent, useState, Ref, useCallback } from 'react'; import styled, { AnyStyledComponent, css } from 'styled-components'; import { shallowEqual, useSelector } from 'react-redux'; import type { NumberFormatValues, SourceInfo } from 'react-number-format'; @@ -7,6 +7,8 @@ import { AlertType } from '@/constants/alerts'; import { ErrorType, + type HumanReadablePlaceOrderPayload, + type Nullable, TradeInputErrorAction, TradeInputField, ValidationError, @@ -19,6 +21,7 @@ import { InputErrorData, TradeBoxKeys, MobilePlaceOrderSteps } from '@/constants import { breakpoints } from '@/styles'; import { useStringGetter, useSubaccount } from '@/hooks'; +import { useOnLastOrderIndexed } from '@/hooks/useOnLastOrderIndexed'; import { layoutMixins } from '@/styles/layoutMixins'; import { formMixins } from '@/styles/formMixins'; @@ -34,7 +37,6 @@ import { WithTooltip } from '@/components/WithTooltip'; import { Orderbook } from '@/views/tables/Orderbook'; -import { calculateHasUncommittedOrders } from '@/state/accountSelectors'; import { getCurrentInput, useTradeFormData } from '@/state/inputsSelectors'; import { getCurrentMarketConfig } from '@/state/perpetualsSelectors'; @@ -97,7 +99,6 @@ export const TradeForm = ({ } = useTradeFormData(); const { limitPrice, triggerPrice, trailingPercent } = price || {}; - const hasUncommittedOrders = useSelector(calculateHasUncommittedOrders); const currentInput = useSelector(getCurrentInput); const { tickSizeDecimals, stepSizeDecimals } = useSelector(getCurrentMarketConfig, shallowEqual) || {}; @@ -154,30 +155,33 @@ export const TradeForm = ({ } }; - useEffect(() => { - // order has been placed - if ( - (!currentStep || currentStep === MobilePlaceOrderSteps.PlacingOrder) && - !hasUncommittedOrders - ) { + const onLastOrderIndexed = useCallback(() => { + if (!currentStep || currentStep === MobilePlaceOrderSteps.PlacingOrder) { setIsPlacingOrder(false); abacusStateManager.clearTradeInputValues({ shouldResetSize: true }); setCurrentStep?.(MobilePlaceOrderSteps.Confirmation); } - }, [currentStep, hasUncommittedOrders]); + }, [currentStep]); + + const { setUnIndexedClientId } = useOnLastOrderIndexed({ + callback: onLastOrderIndexed, + }); const onPlaceOrder = async () => { setPlaceOrderError(undefined); setIsPlacingOrder(true); await placeOrder({ - onError: (errorParams?: { errorStringKey?: string }) => { + onError: (errorParams?: { errorStringKey?: Nullable }) => { setPlaceOrderError( stringGetter({ key: errorParams?.errorStringKey || STRING_KEYS.SOMETHING_WENT_WRONG }) ); setIsPlacingOrder(false); }, + onSuccess: (placeOrderPayload?: Nullable) => { + setUnIndexedClientId(placeOrderPayload?.clientId); + }, }); }; diff --git a/src/views/forms/TradeForm/PlaceOrderButtonAndReceipt.tsx b/src/views/forms/TradeForm/PlaceOrderButtonAndReceipt.tsx index f7468aa..522128f 100644 --- a/src/views/forms/TradeForm/PlaceOrderButtonAndReceipt.tsx +++ b/src/views/forms/TradeForm/PlaceOrderButtonAndReceipt.tsx @@ -136,7 +136,7 @@ export const PlaceOrderButtonAndReceipt = ({ const buttonState = currentStep ? buttonStatesPerStep[currentStep].buttonState - : { isDisabled: !shouldEnableTrade, isLoading }; + : { isDisabled: !shouldEnableTrade || isLoading, isLoading }; return ( diff --git a/src/views/tables/OrdersTable/OrderActionsCell.tsx b/src/views/tables/OrdersTable/OrderActionsCell.tsx index 3cc17ba..e2fac52 100644 --- a/src/views/tables/OrdersTable/OrderActionsCell.tsx +++ b/src/views/tables/OrdersTable/OrderActionsCell.tsx @@ -1,5 +1,5 @@ -import { useCallback, useState } from 'react'; -import { useDispatch } from 'react-redux'; +import { useCallback, useEffect, useState } from 'react'; +import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import styled, { AnyStyledComponent } from 'styled-components'; import { AbacusOrderStatus, type OrderStatus } from '@/constants/abacus'; @@ -29,8 +29,7 @@ export const OrderActionsCell = ({ orderId, status, isDisabled }: ElementProps) const onCancel = useCallback(async () => { setIsCanceling(true); - await cancelOrder({ orderId }); - setIsCanceling(false); + await cancelOrder({ orderId, onError: () => setIsCanceling(false) }); }, []); return (