New Market Widget (#234)

* 🚧 New Market Form

* use dev-5 as default

* Additional UI work

* Add mock data

* 💄 More UI items

* 💄 add preview step

* 💄 Disable proposal button if not enough native tokens

* ✏️ Add disclaimer

* ✏️ fix combobox search

* 🚧 clean up components

* Add filters, modify, button

*  feat: Add details to New Market Dialog

* add assetName

* add helper method - spagetti code

* Update NewMarketMessageDetailsDialog, attempt to hook up client call

* 🚨 fix mobile safari overflow

* update init deposit to 10_001 whole token

* reduce delay block to 5

* Update mock data

* 🚧 SO FRIGGIN CLOSE

* 💄 style/ux nits

* add gov to registry

* PLS

* IT FUCKING WORKS

* Add assets

* FIX TICKER

* ADD NEW ASSETICON

* button width

* change default env to dev

* Remove mention of Impersonation dialog

* Market Search entry point

* uncomment feature

* Clean up NewMarketStep components

* Restore env.json

* Add space T.T

* useGlobalCommands fix types

* 🚧 feat: useNextClobPairId hook WIP

* Add potentialMarkets hook to parse CSV and hide new market entrypoints

* Use updated stringKeys

* Update localization, import nits

* bump v4-client

* add gov vars

* new useGovernanceVariables

* Add validator client calls: proposal fetch/submission

* Update token usage, utilize gov vars

* remove console log

* import nits

* NewMarketMessageDetailsDialog: Fix initial_deposit_amount

* NewMarketAgreement Dialog

* confirm flow

* Remove initialDepositAmount from mainnet env

* NewMarket: Add stringParams to step3

* Update csv

* update env.json add localization changes

* cleanup initialDepositAmountBN and decimals

* ^

* use undefined in place of 0 for DiffOutput

* remove hardcoded string

* Remove potentialMarket from csv

* Ensure user is out of liquidity tier modification

* bump localization, add additional details to receipts

* feedback addressed

* Add margin instead of space

* margin/padding nits, shorten filter method, remove ?. chaining

* additional feedback

---------

Co-authored-by: Taehoon Lee <19664986+ttl33@users.noreply.github.com>
This commit is contained in:
Jared Vu
2024-01-30 11:59:16 -08:00
committed by GitHub
co-authored by Taehoon Lee
parent 13d6610492
commit 793b522487
39 changed files with 3397 additions and 147 deletions
+8 -2
View File
@@ -5,7 +5,7 @@ import { WagmiConfig } from 'wagmi';
import { QueryClient, QueryClientProvider } from 'react-query';
import { GrazProvider } from 'graz';
import { AppRoute, DEFAULT_TRADE_ROUTE } from '@/constants/routes';
import { AppRoute, DEFAULT_TRADE_ROUTE, MarketsRoute } from '@/constants/routes';
import {
useBreakpoints,
@@ -21,6 +21,7 @@ import { DialogAreaProvider, useDialogArea } from '@/hooks/useDialogArea';
import { LocaleProvider } from '@/hooks/useLocaleSeparators';
import { NotificationsProvider } from '@/hooks/useNotifications';
import { LocalNotificationsProvider } from '@/hooks/useLocalNotifications';
import { PotentialMarketsProvider } from '@/hooks/usePotentialMarkets';
import { RestrictionProvider } from '@/hooks/useRestrictions';
import { SubaccountProvider } from '@/hooks/useSubaccount';
@@ -44,6 +45,7 @@ import '@/styles/constants.css';
import '@/styles/fonts.css';
import '@/styles/web3modal.css';
const NewMarket = lazy(() => import('@/pages/markets/NewMarket'));
const MarketsPage = lazy(() => import('@/pages/markets/Markets'));
const PortfolioPage = lazy(() => import('@/pages/portfolio/Portfolio'));
const AlertsPage = lazy(() => import('@/pages/AlertsPage'));
@@ -81,7 +83,10 @@ const Content = () => {
<Route path={AppRoute.Trade} element={<TradePage />} />
</Route>
<Route path={AppRoute.Markets} element={<MarketsPage />} />
<Route path={AppRoute.Markets}>
<Route path={MarketsRoute.New} element={<NewMarket />} />
<Route path={AppRoute.Markets} element={<MarketsPage />} />
</Route>
<Route path={`/${chainTokenLabel}`} element={<RewardsPage />} />
{isTablet && (
<>
@@ -136,6 +141,7 @@ const providers = [
wrapProvider(LocalNotificationsProvider),
wrapProvider(NotificationsProvider),
wrapProvider(DialogAreaProvider),
wrapProvider(PotentialMarketsProvider),
wrapProvider(AppThemeProvider),
];
+1
View File
@@ -16,6 +16,7 @@ const assetIcons = {
AVAX: '/currencies/avax.png',
BCH: '/currencies/bch.png',
BLUR: '/currencies/blur.png',
BONK: '/currencies/bonk.png',
BTC: '/currencies/btc.png',
CELO: '/currencies/celo.png',
COMP: '/currencies/comp.png',
+3 -2
View File
@@ -51,8 +51,9 @@ export const ComboboxMenu = <MenuItemValue extends string, MenuGroupValue extend
// value={highlightedCommand}
// onValueChange={setHighlightedCommand}
filter={(value: string, search: string) => {
if (value.replace(/ /g, '').includes(search.replace(/ /g, ''))) return 1;
return 0;
value.replace(/ /g, '').toLowerCase().includes(search.replace(/ /g, '').toLowerCase())
? 1
: 0;
}}
className={className}
$withStickyLayout={withStickyLayout}
+3 -3
View File
@@ -32,7 +32,7 @@ export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
isValidationAttached={validationConfig?.attached}
>
<Styled.InputContainer hasSlotRight={!!slotRight}>
<Styled.WithLabel label={label} inputID={id}>
<Styled.WithLabel label={label} inputID={id} disabled={otherProps?.disabled}>
<Input ref={ref} id={id} {...otherProps} />
</Styled.WithLabel>
{slotRight}
@@ -85,11 +85,11 @@ Styled.InputContainer = styled.div<{ hasSlotRight?: boolean }>`
`}
`;
Styled.WithLabel = styled(WithLabel)`
Styled.WithLabel = styled(WithLabel)<{ disabled?: boolean }>`
${formMixins.inputLabel}
label {
cursor: text;
${({ disabled }) => !disabled && 'cursor: text;'}
padding: var(--form-input-paddingY) var(--form-input-paddingX) 0;
}
`;
@@ -11,6 +11,7 @@ export const LoadingSpinner: React.FC<{
return (
<Styled.Spinner className={className}>
<Styled.LoadingSpinnerSvg
id={id}
width="38"
height="38"
viewBox="0 0 38 38"
+1
View File
@@ -116,6 +116,7 @@ export const Output = ({
{value?.toString() ?? null}
{tag && <Tag>{tag}</Tag>}
{slotRight}
</Styled.Text>
);
}
+1 -1
View File
@@ -40,5 +40,5 @@ Styled.Details = styled(Details)`
padding: 0.375rem 0.75rem 0.25rem;
font-size: 0.8125em;
font-size: var(--details-item-fontSize, 0.8125em);
`;
+2
View File
@@ -19,6 +19,8 @@ export enum DialogTypes {
Transfer = 'Transfer',
Withdraw = 'Withdraw',
ManageFunds = 'ManageFunds',
NewMarketMessageDetails = 'NewMarketMessageDetails',
NewMarketAgreement = 'NewMarketAgreement',
}
export enum TradeBoxDialogTypes {
+23
View File
@@ -0,0 +1,23 @@
/**
* Temporary Indexer types
* remove when Indexer type lib is available through @dydxprotocol/v4-client-js
*/
export type PerpetualMarketResponse = {
clobPairId: string;
ticker: string;
status: string;
oraclePrice: string;
priceChange24H: string;
volume24H: string;
trades24H: number;
nextFundingRate: string;
initialMarginFraction: string;
maintenanceMarginFraction: string;
openInterest: string;
atomicResolution: number;
quantumConversionExponent: number;
tickSize: string;
stepSize: string;
stepBaseQuantums: number;
subticksPerTick: number;
};
+85
View File
@@ -0,0 +1,85 @@
export type ExchangeConfigParsedCsv = Array<{
base_asset: string;
exchange: string;
pair: string;
adjust_by_market: string;
min_2_depth: string;
avg_30d_vol: string;
reference_price: string;
risk_assessment: string;
num_oracles: string;
liquidity_tier: string;
asset_name: string;
}>;
export type ExchangeConfigItem = {
exchangeName: string;
ticker: string;
adjustByMarket?: string;
};
export type PotentialMarketParsedCsv = Array<{
base_asset: string;
reference_price: string;
num_oracles: string;
liquidity_tier: string;
asset_name: string;
p: string;
atomic_resolution: string;
min_exchanges: string;
min_price_change_ppm: string;
price_exponent: string;
step_base_quantum: string;
ticksize_exponent: string;
subticks_per_tick: string;
min_order_size: string;
quantum_conversion_exponent: string;
}>;
export type PotentialMarketItem = {
baseAsset: string;
referencePrice: string;
numOracles: number;
liquidityTier: number;
assetName: string;
p: number;
atomicResolution: number;
minExchanges: number;
minPriceChangePpm: number;
priceExponent: number;
stepBaseQuantum: number;
ticksizeExponent: number;
subticksPerTick: number;
minOrderSize: number;
quantumConversionExponent: number;
};
export const NUM_ORACLES_TO_QUALIFY_AS_SAFE = 6;
export const LIQUIDITY_TIERS = {
0: {
label: 'Large-cap',
initialMarginFraction: 0.05,
maintenanceMarginFraction: 0.03,
impactNotional: 10_000,
},
1: {
label: 'Mid-cap',
initialMarginFraction: 0.1,
maintenanceMarginFraction: 0.05,
impactNotional: 5_000,
},
2: {
label: 'Long-tail',
initialMarginFraction: 0.2,
maintenanceMarginFraction: 0.1,
impactNotional: 2_500,
},
3: {
label: 'Safety',
initialMarginFraction: 1,
maintenanceMarginFraction: 0.2,
impactNotional: 2_500,
},
};
+4
View File
@@ -11,6 +11,10 @@ export enum AppRoute {
Privacy = '/privacy',
}
export enum MarketsRoute {
New = 'new',
}
export enum PortfolioRoute {
Fees = 'fees',
History = 'history',
+5 -2
View File
@@ -57,10 +57,9 @@ export const tradeTooltips: TooltipStrings = {
title: stringGetter({ key: TOOLTIP_STRING_KEYS.INDEX_PRICE_TITLE }),
body: stringGetter({ key: TOOLTIP_STRING_KEYS.INDEX_PRICE_BODY }),
}),
'initial-margin-fraction': ({ stringGetter, urlConfigs }) => ({
'initial-margin-fraction': ({ stringGetter }) => ({
title: stringGetter({ key: TOOLTIP_STRING_KEYS.INITIAL_MARGIN_FRACTION_TITLE }),
body: stringGetter({ key: TOOLTIP_STRING_KEYS.INITIAL_MARGIN_FRACTION_BODY }),
learnMoreLink: urlConfigs?.initialMarginFractionLearnMore,
}),
'initial-stop': ({ stringGetter }) => ({
title: stringGetter({ key: TOOLTIP_STRING_KEYS.INITIAL_STOP_TITLE }),
@@ -163,6 +162,10 @@ export const tradeTooltips: TooltipStrings = {
title: stringGetter({ key: TOOLTIP_STRING_KEYS.REDUCE_ONLY_TIMEINFORCE_IOC_FOK_TITLE }),
body: stringGetter({ key: TOOLTIP_STRING_KEYS.REDUCE_ONLY_TIMEINFORCE_IOC_FOK_BODY }),
}),
'reference-price': ({ stringGetter }) => ({
title: stringGetter({ key: TOOLTIP_STRING_KEYS.REFERENCE_PRICE_TITLE }),
body: stringGetter({ key: TOOLTIP_STRING_KEYS.REFERENCE_PRICE_BODY }),
}),
spread: () => ({
title: 'Spread',
body: 'The difference in price between the highest bid (the price a buyer is willing to buy for) and lowest ask (the price a seller is willing to sell for) an asset.',
+4
View File
@@ -7,6 +7,7 @@ import { useDebounce } from './useDebounce';
import { useInterval } from './useInterval';
import { useDocumentTitle } from './useDocumentTitle';
import { useDydxClient } from './useDydxClient';
import { useGovernanceVariables } from './useGovernanceVariables';
import { useAccountBalance } from './useAccountBalance';
import { useAccounts } from './useAccounts';
import { useAnalytics } from './useAnalytics';
@@ -14,6 +15,7 @@ import { useInitializePage } from './useInitializePage';
import { useIsFirstRender } from './useIsFirstRender';
import { useLocaleSeparators } from './useLocaleSeparators';
import { useLocalStorage } from './useLocalStorage';
import { useNextClobPairId } from './useNextClobPairId';
import { useNow } from './useNow';
import { useOnClickOutside } from './useOnClickOutside';
import { usePageTitlePriceUpdates } from './usePageTitlePriceUpdates';
@@ -34,6 +36,7 @@ export {
useDebounce,
useDocumentTitle,
useDydxClient,
useGovernanceVariables,
useAccountBalance,
useAccounts,
useAnalytics,
@@ -42,6 +45,7 @@ export {
useIsFirstRender,
useLocaleSeparators,
useLocalStorage,
useNextClobPairId,
useNow,
useOnClickOutside,
usePageTitlePriceUpdates,
+43 -5
View File
@@ -1,5 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import {
BECH32_PREFIX,
CompositeClient,
@@ -9,6 +10,7 @@ import {
onboarding,
Network,
ValidatorConfig,
type ProposalStatus,
} from '@dydxprotocol/v4-client-js';
import type { ResolutionString } from 'public/tradingview/charting_library';
@@ -68,17 +70,21 @@ const useDydxClientContext = () => {
new Network(
selectedNetwork,
new IndexerConfig(networkConfig.indexerUrl, networkConfig.websocketUrl),
new ValidatorConfig(networkConfig.validatorUrl, networkConfig.chainId,
new ValidatorConfig(
networkConfig.validatorUrl,
networkConfig.chainId,
{
USDC_DENOM: tokensConfigs[DydxChainAsset.USDC].denom,
USDC_DECIMALS: tokensConfigs[DydxChainAsset.USDC].decimals,
USDC_GAS_DENOM: tokensConfigs[DydxChainAsset.USDC].gasDenom,
CHAINTOKEN_DENOM: tokensConfigs[DydxChainAsset.CHAINTOKEN].denom,
CHAINTOKEN_DECIMALS: tokensConfigs[DydxChainAsset.CHAINTOKEN].decimals,
}, {
broadcastPollIntervalMs: 3_000,
broadcastTimeoutMs: 60_000,
})
},
{
broadcastPollIntervalMs: 3_000,
broadcastTimeoutMs: 60_000,
}
)
)
);
setCompositeClient(initializedClient);
@@ -111,6 +117,36 @@ const useDydxClientContext = () => {
};
// ------ Public Methods ------ //
const requestAllPerpetualMarkets = useCallback(async () => {
try {
const { markets } =
(await compositeClient?.indexerClient.markets.getPerpetualMarkets()) || {};
return markets || [];
} catch (error) {
log('useDydxClient/getPerpetualMarkets', error);
return [];
}
}, [compositeClient]);
/**
* @param proposalStatus - Optional filter for proposal status. If not provided, all proposals in ProposalStatus.VotingPeriod will be returned.
*/
const requestAllGovernanceProposals = useCallback(
async (proposalStatus?: ProposalStatus) => {
try {
const allGovProposals = await compositeClient?.validatorClient.get.getAllGovProposals(
proposalStatus
);
return allGovProposals;
} catch (error) {
log('useDydxClient/getProposals', error);
return undefined;
}
},
[compositeClient]
);
const requestCandles = useCallback(
async ({
marketId,
@@ -225,6 +261,8 @@ const useDydxClientContext = () => {
getWalletFromEvmSignature,
// Public Methods
requestAllPerpetualMarkets,
requestAllGovernanceProposals,
getCandlesForDatafeed,
screenAddresses,
};
+16
View File
@@ -0,0 +1,16 @@
import { ENVIRONMENT_CONFIG_MAP } from '@/constants/networks';
import { useSelectedNetwork } from '@/hooks';
export interface GovernanceVariables {
newMarketProposal: {
initialDepositAmount: number;
delayBlocks: number;
newMarketsMethodology: string;
};
}
export const useGovernanceVariables = (): GovernanceVariables => {
const { selectedNetwork } = useSelectedNetwork();
const governanceVars = ENVIRONMENT_CONFIG_MAP[selectedNetwork].governance as GovernanceVariables;
return governanceVars;
};
+118
View File
@@ -0,0 +1,118 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
import {
MsgCreateClobPair,
MsgCreateOracleMarket,
MsgCreatePerpetual,
MsgDelayMessage,
MsgUpdateClobPair,
TYPE_URL_MSG_CREATE_CLOB_PAIR,
TYPE_URL_MSG_CREATE_ORACLE_MARKET,
TYPE_URL_MSG_CREATE_PERPETUAL,
TYPE_URL_MSG_DELAY_MESSAGE,
TYPE_URL_MSG_UPDATE_CLOB_PAIR,
} from '@dydxprotocol/v4-client-js';
import type { PerpetualMarketResponse } from '@/constants/indexer';
import { useDydxClient } from '@/hooks/useDydxClient';
export const useNextClobPairId = () => {
const { isConnected, requestAllPerpetualMarkets, requestAllGovernanceProposals } =
useDydxClient();
const { data: perpetualMarkets, status: perpetualMarketsStatus } = useQuery({
enabled: isConnected,
queryKey: 'requestAllPerpetualMarkets',
queryFn: requestAllPerpetualMarkets,
refetchInterval: 60_000,
staleTime: 60_000,
});
const { data: allGovProposals, status: allGovProposalsStatus } = useQuery({
enabled: isConnected,
queryKey: 'requestAllActiveGovernanceProposals',
queryFn: () => requestAllGovernanceProposals(),
refetchInterval: 10_000,
staleTime: 10_000,
});
/**
*
* @param message from proposal. Each message is wrapped in a type any (on purpose).
* @param callback method used to compile all clobPairIds, perpetualIds, marketIds, etc.
*/
const decodeMsgForClobPairId = (message: any, callback: (id?: number) => void): any => {
const { typeUrl, value } = message;
switch (typeUrl) {
case TYPE_URL_MSG_CREATE_ORACLE_MARKET: {
const decodedValue = MsgCreateOracleMarket.decode(value);
callback(decodedValue.params?.id);
break;
}
case TYPE_URL_MSG_CREATE_PERPETUAL: {
const decodedValue = MsgCreatePerpetual.decode(value);
callback(decodedValue.params?.id);
callback(decodedValue.params?.marketId);
break;
}
case TYPE_URL_MSG_CREATE_CLOB_PAIR: {
const decodedValue = MsgCreateClobPair.decode(value);
callback(decodedValue.clobPair?.id);
callback(decodedValue.clobPair?.perpetualClobMetadata?.perpetualId);
break;
}
case TYPE_URL_MSG_UPDATE_CLOB_PAIR: {
const decodedValue = MsgUpdateClobPair.decode(value);
callback(decodedValue.clobPair?.id);
callback(decodedValue.clobPair?.perpetualClobMetadata?.perpetualId);
break;
}
case TYPE_URL_MSG_DELAY_MESSAGE: {
const decodedValue = MsgDelayMessage.decode(value);
decodeMsgForClobPairId(decodedValue.msg, callback);
break;
}
default: {
break;
}
}
};
const nextAvailableClobPairId = useMemo(() => {
const idsFromProposals: number[] = [];
if (allGovProposals && Object.values(allGovProposals.proposals).length > 0) {
const proposals = allGovProposals.proposals;
proposals.forEach((proposal) => {
if (proposal.messages) {
proposal.messages.map((message) => {
decodeMsgForClobPairId(message, (id?: number) => {
if (id) {
idsFromProposals.push(id);
}
});
});
}
});
}
if (perpetualMarkets && Object.values(perpetualMarkets).length > 0) {
const clobPairIds = Object.values(perpetualMarkets)?.map((perpetualMarket) =>
Number((perpetualMarket as PerpetualMarketResponse).clobPairId)
);
const nextAvailableClobPairId = Math.max(...[...clobPairIds, ...idsFromProposals]) + 1;
return nextAvailableClobPairId;
}
return undefined;
}, [perpetualMarkets, allGovProposals]);
return {
allGovProposalsStatus,
perpetualMarketsStatus,
nextAvailableClobPairId,
};
};
+135
View File
@@ -0,0 +1,135 @@
import { createContext, useContext, useEffect, useState } from 'react';
import type {
ExchangeConfigItem,
ExchangeConfigParsedCsv,
PotentialMarketItem,
PotentialMarketParsedCsv,
} from '@/constants/potentialMarkets';
import csvToArray from '@/lib/csvToArray';
import { log } from '@/lib/telemetry';
const PotentialMarketsContext = createContext<ReturnType<typeof usePotentialMarketsContext>>({
potentialMarkets: undefined,
exchangeConfigs: undefined,
hasPotentialMarketsData: false,
});
PotentialMarketsContext.displayName = 'PotentialMarkets';
export const PotentialMarketsProvider = ({ ...props }) => (
<PotentialMarketsContext.Provider value={usePotentialMarketsContext()} {...props} />
);
export const usePotentialMarkets = () => useContext(PotentialMarketsContext);
const EXCHANGE_CONFIG_FILE_PATH = '/configs/potentialMarketExchangeConfig.csv';
const POTENTIAL_MARKETS_FILE_PATH = '/configs/potentialMarketParameters.csv';
export const usePotentialMarketsContext = () => {
const [potentialMarkets, setPotentialMarkets] = useState<PotentialMarketItem[]>();
const [exchangeConfigs, setExchangeConfigs] = useState<Record<string, ExchangeConfigItem[]>>();
useEffect(() => {
try {
fetch(POTENTIAL_MARKETS_FILE_PATH)
.then((response) => response.text())
.then((data) => {
const parsedData = csvToArray<PotentialMarketParsedCsv>({
stringVal: data,
splitter: ',',
});
const parsedPotentialMarkets = parsedData.map(
({
base_asset,
reference_price,
num_oracles,
liquidity_tier,
asset_name,
p,
atomic_resolution,
min_exchanges,
min_price_change_ppm,
price_exponent,
step_base_quantum,
ticksize_exponent,
subticks_per_tick,
min_order_size,
quantum_conversion_exponent,
}) => ({
// convert to camelCase
baseAsset: base_asset,
referencePrice: reference_price,
numOracles: Number(num_oracles),
liquidityTier: Number(liquidity_tier),
assetName: asset_name,
p: Number(p),
atomicResolution: Number(atomic_resolution),
minExchanges: Number(min_exchanges),
minPriceChangePpm: Number(min_price_change_ppm),
priceExponent: Number(price_exponent),
stepBaseQuantum: Number(step_base_quantum),
ticksizeExponent: Number(ticksize_exponent),
subticksPerTick: Number(subticks_per_tick),
minOrderSize: Number(min_order_size),
quantumConversionExponent: Number(quantum_conversion_exponent),
})
);
setPotentialMarkets(parsedPotentialMarkets);
});
} catch (error) {
log('usePotentialMarkets/potentialMarkets', error);
setPotentialMarkets(undefined);
}
try {
fetch(EXCHANGE_CONFIG_FILE_PATH)
.then((response) => response.text())
.then((data) => {
const parsedData = csvToArray<ExchangeConfigParsedCsv>({
stringVal: data,
splitter: ',',
});
// create an object with the base_asset as the key and the value as an array of exchanges
const exchangeConfigMap = parsedData.reduce(
(acc: Record<string, ExchangeConfigItem[]>, curr) => {
const { base_asset, exchange, pair, adjust_by_market } = curr;
if (!acc[base_asset]) {
acc[base_asset] = [];
}
const exchangeItem: {
exchangeName: string;
ticker: string;
adjustByMarket?: string;
} = {
exchangeName: exchange,
ticker: pair,
};
if (adjust_by_market) {
exchangeItem.adjustByMarket = adjust_by_market;
}
acc[base_asset].push(exchangeItem);
return acc;
},
{}
);
setExchangeConfigs(exchangeConfigMap);
});
} catch (error) {
log('usePotentialMarkets/exchangeConfigs', error);
setExchangeConfigs(undefined);
}
}, []);
return {
potentialMarkets,
exchangeConfigs,
hasPotentialMarketsData: Boolean(potentialMarkets && exchangeConfigs),
};
};
+43 -12
View File
@@ -2,11 +2,16 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState }
import { shallowEqual, useSelector, useDispatch } from 'react-redux';
import type { Nullable } from '@dydxprotocol/v4-abacus';
import Long from 'long';
import type { IndexedTx } from '@cosmjs/stargate';
import { type IndexedTx } from '@cosmjs/stargate';
import type { EncodeObject } from '@cosmjs/proto-signing';
import { Method } from '@cosmjs/tendermint-rpc';
import { type LocalWallet, SubaccountClient } from '@dydxprotocol/v4-client-js';
import {
type LocalWallet,
SubaccountClient,
type GovAddNewMarketParams,
utils,
} from '@dydxprotocol/v4-client-js';
import type {
AccountBalance,
@@ -16,7 +21,6 @@ import type {
} from '@/constants/abacus';
import { AMOUNT_RESERVED_FOR_GAS_USDC } from '@/constants/account';
import { AnalyticsEvent } from '@/constants/analytics';
import { QUANTUM_MULTIPLIER } from '@/constants/numbers';
import { DydxAddress } from '@/constants/wallets';
@@ -24,14 +28,13 @@ import { setSubaccount, setHistoricalPnl, removeUncommittedOrderClientId } from
import { getBalances } from '@/state/accountSelectors';
import abacusStateManager from '@/lib/abacus';
import { track } from '@/lib/analytics';
import { MustBigNumber } from '@/lib/numbers';
import { hashFromTx } from '@/lib/hashfromTx';
import { log } from '@/lib/telemetry';
import { useAccounts } from './useAccounts';
import { useTokenConfigs } from './useTokenConfigs';
import { useDydxClient } from './useDydxClient';
import { hashFromTx } from '@/lib/hashfromTx';
import { useGovernanceVariables } from './useGovernanceVariables';
type SubaccountContextType = ReturnType<typeof useSubaccountContext>;
const SubaccountContext = createContext<SubaccountContextType>({} as SubaccountContextType);
@@ -201,8 +204,8 @@ export const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: Lo
value: {
...transaction.msg,
timeoutTimestamp: transaction.msg.timeoutTimestamp
// Squid returns timeoutTimestamp as Long, but the signer expects BigInt
? BigInt(Long.fromValue(transaction.msg.timeoutTimestamp).toString())
? // Squid returns timeoutTimestamp as Long, but the signer expects BigInt
BigInt(Long.fromValue(transaction.msg.timeoutTimestamp).toString())
: undefined,
},
};
@@ -301,9 +304,8 @@ export const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: Lo
const sendSquidWithdraw = useCallback(
async (amount: number, payload: string, isCctp?: boolean) => {
const cctpWithdraw = () => {
return new Promise<string>((resolve, reject) =>
return new Promise<string>((resolve, reject) =>
abacusStateManager.cctpWithdraw((success, error, data) => {
const parsedData = JSON.parse(data);
if (success && parsedData?.code == 0) {
@@ -312,8 +314,8 @@ export const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: Lo
reject(error);
}
})
)
}
);
};
if (isCctp) {
return await cctpWithdraw();
}
@@ -413,6 +415,32 @@ export const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: Lo
[subaccountClient]
);
const { newMarketProposal } = useGovernanceVariables();
// ------ Governance Methods ------ //
const submitNewMarketProposal = useCallback(
async (params: GovAddNewMarketParams) => {
if (!compositeClient) {
throw new Error('client not initialized');
} else if (!localDydxWallet) {
throw new Error('wallet not initialized');
} else if (!newMarketProposal) {
throw new Error('governance variables not initialized');
}
const response = await compositeClient.submitGovAddNewMarketProposal(
localDydxWallet,
params,
utils.getGovAddNewMarketTitle(params.ticker),
utils.getGovAddNewMarketSummary(params.ticker, newMarketProposal.delayBlocks),
newMarketProposal.initialDepositAmount
);
return response;
},
[compositeClient, localDydxWallet]
);
return {
// Deposit/Withdraw/Faucet Methods
deposit,
@@ -427,5 +455,8 @@ export const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: Lo
placeOrder,
closePosition,
cancelOrder,
// Governance Methods
submitNewMarketProposal,
};
};
+4
View File
@@ -27,6 +27,8 @@ import { ManageFundsDialog } from '@/views/dialogs/ManageFundsDialog';
import { OrderDetailsDialog } from '@/views/dialogs/DetailsDialog/OrderDetailsDialog';
import { FillDetailsDialog } from '@/views/dialogs/DetailsDialog/FillDetailsDialog';
import { NewMarketMessageDetailsDialog } from '@/views/dialogs/NewMarketMessageDetailsDialog';
import { NewMarketAgreementDialog } from '@/views/dialogs/NewMarketAgreementDialog';
export const DialogManager = () => {
const dispatch = useDispatch();
@@ -67,5 +69,7 @@ export const DialogManager = () => {
[DialogTypes.Transfer]: <TransferDialog {...modalProps} />,
[DialogTypes.Withdraw]: <WithdrawDialog {...modalProps} />,
[DialogTypes.ManageFunds]: <ManageFundsDialog {...modalProps} />,
[DialogTypes.NewMarketMessageDetails]: <NewMarketMessageDetailsDialog {...modalProps} />,
[DialogTypes.NewMarketAgreement]: <NewMarketAgreementDialog {...modalProps} />,
}[type];
};
+16
View File
@@ -0,0 +1,16 @@
const csvToArray = <T>({ stringVal, splitter }: { stringVal: string; splitter: string }) => {
const [keys, ...rest] = stringVal
.trim()
.split('\n')
.map((item) => item.split(splitter));
const formedArr = rest.map((item) => {
const object: Record<string, string> = {};
keys.forEach((key, index) => (object[key] = item[index]));
return object;
});
return formedArr as T;
};
export default csvToArray;
+13 -2
View File
@@ -1,21 +1,25 @@
import styled, { AnyStyledComponent } from 'styled-components';
import { useNavigate } from 'react-router-dom';
import { breakpoints } from '@/styles';
import { STRING_KEYS } from '@/constants/localization';
import { AppRoute, MarketsRoute } from '@/constants/routes';
import { useBreakpoints, useDocumentTitle, useStringGetter } from '@/hooks';
import { usePotentialMarkets } from '@/hooks/usePotentialMarkets';
import { layoutMixins } from '@/styles/layoutMixins';
import { Button } from '@/components/Button';
import { ContentSectionHeader } from '@/components/ContentSectionHeader';
import { ExchangeBillboards } from '@/views/ExchangeBillboards';
import { MarketsTable } from '@/views/tables/MarketsTable';
const Markets = () => {
const stringGetter = useStringGetter();
const { isNotTablet } = useBreakpoints();
const navigate = useNavigate();
const { hasPotentialMarketsData } = usePotentialMarkets();
useDocumentTitle(stringGetter({ key: STRING_KEYS.MARKETS }));
@@ -25,6 +29,13 @@ const Markets = () => {
<Styled.ContentSectionHeader
title={stringGetter({ key: STRING_KEYS.MARKETS })}
subtitle={isNotTablet && stringGetter({ key: STRING_KEYS.DISCOVER_NEW_ASSETS })}
slotRight={
hasPotentialMarketsData && (
<Button onClick={() => navigate(`${AppRoute.Markets}/${MarketsRoute.New}`)}>
{stringGetter({ key: STRING_KEYS.ADD_A_MARKET })}
</Button>
)
}
/>
<Styled.ExchangeBillboards isSearching={false} searchQuery="" />
</Styled.HeaderSection>
+259
View File
@@ -0,0 +1,259 @@
import { useMemo, useState } from 'react';
import styled, { AnyStyledComponent } from 'styled-components';
import { useNavigate } from 'react-router-dom';
import { STRING_KEYS } from '@/constants/localization';
import { isMainnet } from '@/constants/networks';
import { AppRoute } from '@/constants/routes';
import {
useBreakpoints,
useDocumentTitle,
useGovernanceVariables,
useStringGetter,
useTokenConfigs,
} from '@/hooks';
import { breakpoints } from '@/styles';
import { layoutMixins } from '@/styles/layoutMixins';
import { Button } from '@/components/Button';
import { ContentSectionHeader } from '@/components/ContentSectionHeader';
import { IconButton } from '@/components/IconButton';
import { Icon, IconName } from '@/components/Icon';
import { Link } from '@/components/Link';
import { NewMarketForm } from '@/views/forms/NewMarketForm';
import { MustBigNumber } from '@/lib/numbers';
const StepItem = ({ step, subtitle, title }: { step: number; subtitle: string; title: string }) => (
<Styled.StepItem>
<Styled.StepNumber>{step}</Styled.StepNumber>
<Styled.Column>
<Styled.Title>{title}</Styled.Title>
<Styled.Subtitle>{subtitle}</Styled.Subtitle>
</Styled.Column>
</Styled.StepItem>
);
const NewMarket = () => {
const { isNotTablet } = useBreakpoints();
const { newMarketProposal } = useGovernanceVariables();
const navigate = useNavigate();
const [displaySteps, setDisplaySteps] = useState(true);
const stringGetter = useStringGetter();
const { chainTokenLabel, chainTokenDecimals } = useTokenConfigs();
useDocumentTitle(stringGetter({ key: STRING_KEYS.ADD_A_MARKET }));
const steps = useMemo(() => {
return [
{
step: 1,
title: stringGetter({ key: STRING_KEYS.ADD_MARKET_STEP_1_TITLE }),
subtitle: stringGetter({
key: STRING_KEYS.ADD_MARKET_STEP_1_DESCRIPTION,
params: {
HERE: (
<Styled.Link href={newMarketProposal.newMarketsMethodology}>
{stringGetter({ key: STRING_KEYS.HERE })}
</Styled.Link>
),
},
}),
},
{
step: 2,
title: stringGetter({ key: STRING_KEYS.ADD_MARKET_STEP_2_TITLE }),
subtitle: stringGetter({ key: STRING_KEYS.ADD_MARKET_STEP_2_DESCRIPTION }),
},
{
step: 3,
title: stringGetter({ key: STRING_KEYS.ADD_MARKET_STEP_3_TITLE }),
subtitle: stringGetter({
key: STRING_KEYS.ADD_MARKET_STEP_3_DESCRIPTION,
params: {
REQUIRED_NUM_TOKENS: MustBigNumber(newMarketProposal?.initialDepositAmount)
.div(Number(`1e${chainTokenDecimals}`))
.toFixed(isMainnet ? 0 : chainTokenDecimals),
NATIVE_TOKEN_DENOM: chainTokenLabel,
},
}),
},
];
}, [stringGetter, newMarketProposal, chainTokenLabel]);
return (
<Styled.Page>
<Styled.HeaderSection>
<Styled.ContentSectionHeader
title={stringGetter({ key: STRING_KEYS.SUGGEST_NEW_MARKET })}
slotRight={
<IconButton iconName={IconName.Close} onClick={() => navigate(AppRoute.Markets)} />
}
subtitle={isNotTablet && stringGetter({ key: STRING_KEYS.ADD_DETAILS_TO_LAUNCH_MARKET })}
/>
</Styled.HeaderSection>
<Styled.Content>
<div>
<Button
slotLeft={<Styled.Icon iconName={displaySteps ? IconName.Hide : IconName.HelpCircle} />}
onClick={() => setDisplaySteps(!displaySteps)}
>
{displaySteps
? stringGetter({ key: STRING_KEYS.HIDE_STEPS })
: stringGetter({ key: STRING_KEYS.SHOW_STEPS })}
</Button>
{displaySteps && (
<>
<Styled.StepsTitle>
{stringGetter({ key: STRING_KEYS.STEPS_TO_CREATE })}
</Styled.StepsTitle>
{steps.map((item) => (
<StepItem
key={item.step}
step={item.step}
title={item.title}
subtitle={item.subtitle}
/>
))}
</>
)}
</div>
<Styled.FormContainer>
<NewMarketForm />
</Styled.FormContainer>
</Styled.Content>
</Styled.Page>
);
};
const Styled: Record<string, AnyStyledComponent> = {};
Styled.Page = styled.div`
${layoutMixins.contentContainerPage}
gap: 1.5rem;
> * {
--content-max-width: 80rem;
max-width: min(calc(100vw - 4rem), var(--content-max-width));
}
@media ${breakpoints.tablet} {
--stickyArea-topHeight: var(--page-header-height-mobile);
padding: 0 1rem 1rem;
> * {
max-width: calc(100vw - 2rem);
width: 100%;
}
}
`;
Styled.ContentSectionHeader = styled(ContentSectionHeader)`
@media ${breakpoints.notTablet} {
padding: 1rem 0;
}
@media ${breakpoints.tablet} {
padding: 1.25rem 0;
h3 {
font: var(--font-extra-medium);
}
}
`;
Styled.HeaderSection = styled.section`
${layoutMixins.contentSectionDetached}
@media ${breakpoints.tablet} {
${layoutMixins.flexColumn}
gap: 1rem;
margin-bottom: 0.5rem;
}
`;
Styled.Content = styled.div`
display: flex;
flex-direction: row;
gap: 2rem;
margin: 0 auto;
@media ${breakpoints.tablet} {
display: flex;
flex-direction: column;
gap: 1rem;
margin: 0 auto;
}
`;
Styled.StepsTitle = styled.h2`
font: var(--font-large-medium);
color: var(--color-text-2);
margin: 1rem;
@media ${breakpoints.tablet} {
margin: 1rem 0;
}
`;
Styled.Icon = styled(Icon)`
margin-right: 0.5ch;
`;
Styled.StepItem = styled.div`
display: flex;
flex-direction: row;
gap: 1rem;
align-items: center;
margin-bottom: 1rem;
`;
Styled.StepNumber = styled.div`
width: 2.5rem;
height: 2.5rem;
min-width: 2.5rem;
min-height: 2.5rem;
border-radius: 50%;
background-color: var(--color-layer-5);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-2);
`;
Styled.Column = styled.div`
display: flex;
flex-direction: column;
`;
Styled.Title = styled.span`
color: var(--color-text-2);
font: var(--font-medium-book);
`;
Styled.Subtitle = styled.span`
color: var(--color-text-0);
`;
Styled.Link = styled(Link)`
--link-color: var(--color-accent);
display: inline-block;
`;
Styled.FormContainer = styled.div`
min-width: 31.25rem;
height: fit-content;
border-radius: 1rem;
background-color: var(--color-layer-3);
padding: 1rem;
@media ${breakpoints.tablet} {
width: 100%;
min-width: unset;
}
`;
export default NewMarket;
+103
View File
@@ -0,0 +1,103 @@
import styled, { AnyStyledComponent } from 'styled-components';
import { useNavigate } from 'react-router-dom';
import { ButtonAction, ButtonSize } from '@/constants/buttons';
import { STRING_KEYS } from '@/constants/localization';
import { isMainnet } from '@/constants/networks';
import { AppRoute, MarketsRoute } from '@/constants/routes';
import { useStringGetter, useTokenConfigs } from '@/hooks';
import { usePotentialMarkets } from '@/hooks/usePotentialMarkets';
import { useGovernanceVariables } from '@/hooks/useGovernanceVariables';
import { Panel } from '@/components/Panel';
import { IconName } from '@/components/Icon';
import { IconButton } from '@/components/IconButton';
import { Output, OutputType } from '@/components/Output';
import { Tag } from '@/components/Tag';
import { MustBigNumber } from '@/lib/numbers';
import { layoutMixins } from '@/styles/layoutMixins';
export const NewMarketsPanel = () => {
const stringGetter = useStringGetter();
const navigate = useNavigate();
const { hasPotentialMarketsData } = usePotentialMarkets();
const { chainTokenDecimals, chainTokenLabel } = useTokenConfigs();
const { newMarketProposal } = useGovernanceVariables();
const initialDepositAmountBN = MustBigNumber(newMarketProposal.initialDepositAmount).div(
Number(`1e${chainTokenDecimals}`)
);
const initialDepositAmountDecimals = isMainnet ? 0 : chainTokenDecimals;
if (!hasPotentialMarketsData) return null;
return (
<Panel
slotHeaderContent={
<Styled.Title>
{stringGetter({ key: STRING_KEYS.ADD_A_MARKET })}
<Styled.NewTag>{stringGetter({ key: STRING_KEYS.NEW })}</Styled.NewTag>
</Styled.Title>
}
slotRight={
<Styled.Arrow>
<Styled.IconButton
action={ButtonAction.Base}
iconName={IconName.Arrow}
size={ButtonSize.Small}
/>
</Styled.Arrow>
}
onClick={() => navigate(`${AppRoute.Markets}/${MarketsRoute.New}`)}
>
<Styled.Description>
{stringGetter({
key: STRING_KEYS.NEW_MARKET_REWARDS_ENTRY_DESCRIPTION,
params: {
REQUIRED_NUM_TOKENS: (
<Styled.Output
useGrouping
type={OutputType.Number}
value={initialDepositAmountBN}
fractionDigits={initialDepositAmountDecimals}
/>
),
NATIVE_TOKEN_DENOM: chainTokenLabel,
},
})}
</Styled.Description>
</Panel>
);
};
const Styled: Record<string, AnyStyledComponent> = {};
Styled.Description = styled.div`
color: var(--color-text-0);
`;
Styled.IconButton = styled(IconButton)`
color: var(--color-text-0);
--color-border: var(--color-layer-6);
`;
Styled.Arrow = styled.div`
padding-right: 1.5rem;
`;
Styled.Title = styled.h3`
font: var(--font-medium-book);
color: var(--color-text-2);
margin-bottom: -1rem;
${layoutMixins.inlineRow}
`;
Styled.Output = styled(Output)`
display: inline-block;
`;
Styled.NewTag = styled(Tag)`
color: var(--color-accent);
background-color: var(--color-accent-faded);
`;
+3 -2
View File
@@ -14,11 +14,12 @@ import { BackButton } from '@/components/BackButton';
import { Panel } from '@/components/Panel';
import { DYDXBalancePanel } from './DYDXBalancePanel';
import { MigratePanel } from './MigratePanel';
import { LaunchIncentivesPanel } from './LaunchIncentivesPanel';
import { MigratePanel } from './MigratePanel';
import { RewardsHelpPanel } from './RewardsHelpPanel';
import { GovernancePanel } from './GovernancePanel';
import { StakingPanel } from './StakingPanel';
import { NewMarketsPanel } from './NewMarketsPanel';
const RewardsPage = () => {
const dispatch = useDispatch();
@@ -44,9 +45,9 @@ const RewardsPage = () => {
<DYDXBalancePanel />
</Styled.PanelRowIncentivesAndBalance>
)}
{isNotTablet && (
<Styled.PanelRow>
<NewMarketsPanel />
<GovernancePanel />
<StakingPanel />
</Styled.PanelRow>
+19 -14
View File
@@ -3,23 +3,26 @@ import { useNavigate } from 'react-router-dom';
import styled, { type AnyStyledComponent, css, keyframes } from 'styled-components';
import { useSelector } from 'react-redux';
import { ButtonSize } from '@/constants/buttons';
import { STRING_KEYS } from '@/constants/localization';
import { MarketFilters, type MarketData } from '@/constants/markets';
import { AppRoute } from '@/constants/routes';
import { AppRoute, MarketsRoute } from '@/constants/routes';
import { useStringGetter } from '@/hooks';
import { useMarketsData } from '@/hooks/useMarketsData';
import { usePotentialMarkets } from '@/hooks/usePotentialMarkets';
import { popoverMixins } from '@/styles/popoverMixins';
import { layoutMixins } from '@/styles/layoutMixins';
import { AssetIcon } from '@/components/AssetIcon';
import { Button } from '@/components/Button';
import { Icon, IconName } from '@/components/Icon';
import { Output, OutputType } from '@/components/Output';
import { Popover, TriggerType } from '@/components/Popover';
import { ColumnDef, Table } from '@/components/Table';
import { Tag } from '@/components/Tag';
import { Toolbar } from '@/components/Toolbar';
import { ColumnDef, Table } from '@/components/Table';
import { useMarketsData } from '@/hooks/useMarketsData';
import { getSelectedLocale } from '@/state/localizationSelectors';
import { MustBigNumber } from '@/lib/numbers';
@@ -32,6 +35,8 @@ const MarketsDropdownContent = ({ onRowAction }: { onRowAction?: (market: string
const selectedLocale = useSelector(getSelectedLocale);
const [searchFilter, setSearchFilter] = useState<string>();
const { filteredMarkets, marketFilters } = useMarketsData(filter, searchFilter);
const navigate = useNavigate();
const { hasPotentialMarketsData } = usePotentialMarkets();
return (
<>
@@ -134,16 +139,16 @@ const MarketsDropdownContent = ({ onRowAction }: { onRowAction?: (market: string
})}
</h2>
<p>{stringGetter({ key: STRING_KEYS.MARKET_SEARCH_DOES_NOT_EXIST_YET })}</p>
{/* TODO TRCL-1693 - uncomment when feedback modal is finalized
<div>
<Button
// TODO: uncomment when feedback modal is finalized
// onClick={() => dispatch(openModal({ modalType: MODALS.FEEDBACK }))}
size={ButtonSize.Small}
>
{stringGetter({ key: STRING_KEYS.GIVE_FEEDBACK })}
</Button>
</div> */}
{hasPotentialMarketsData && (
<div>
<Button
onClick={() => navigate(`${AppRoute.Markets}/${MarketsRoute.New}`)}
size={ButtonSize.Small}
>
{stringGetter({ key: STRING_KEYS.PROPOSE_NEW_MARKET })}
</Button>
</div>
)}
</Styled.MarketNotFound>
}
/>
@@ -0,0 +1,105 @@
import { useState } from 'react';
import styled, { AnyStyledComponent } from 'styled-components';
import { ButtonAction } from '@/constants/buttons';
import { AppRoute } from '@/constants/routes';
import { STRING_KEYS } from '@/constants/localization';
import { useStringGetter } from '@/hooks';
import breakpoints from '@/styles/breakpoints';
import { layoutMixins } from '@/styles/layoutMixins';
import { Button } from '@/components/Button';
import { Checkbox } from '@/components/Checkbox';
import { Dialog } from '@/components/Dialog';
import { Link } from '@/components/Link';
type ElementProps = {
acceptTerms: () => void;
setIsOpen: (open: boolean) => void;
};
export const NewMarketAgreementDialog = ({ acceptTerms, setIsOpen }: ElementProps) => {
const [hasAcknowledged, setHasAcknowledged] = useState(false);
const stringGetter = useStringGetter();
return (
<Styled.Dialog
isOpen
setIsOpen={setIsOpen}
title={stringGetter({ key: STRING_KEYS.ACKNOWLEDGEMENT })}
>
<Styled.Content>
<p>
{stringGetter({
key: STRING_KEYS.NEW_MARKET_PROPOSAL_AGREEMENT,
params: {
DOCUMENTATION_LINK: (
<Styled.Link href="https://docs.dydx.community/dydx-governance/voting-and-governance/governance-process">
{stringGetter({ key: STRING_KEYS.WEBSITE }).toLowerCase()}
</Styled.Link>
),
TERMS_OF_USE: (
<Styled.Link href={`/#${AppRoute.Terms}`}>
{stringGetter({ key: STRING_KEYS.TERMS_OF_USE })}
</Styled.Link>
),
},
})}
</p>
<Checkbox
checked={hasAcknowledged}
onCheckedChange={setHasAcknowledged}
id="acknowledgement-checkbox"
label={stringGetter({ key: STRING_KEYS.I_HAVE_READ_AND_AGREE })}
/>
<Styled.ButtonRow>
<Button action={ButtonAction.Base} onClick={() => setIsOpen(false)}>
{stringGetter({ key: STRING_KEYS.CANCEL })}
</Button>
<Button
action={ButtonAction.Primary}
onClick={() => {
acceptTerms();
setIsOpen(false);
}}
state={{ isDisabled: !hasAcknowledged }}
>
{stringGetter({ key: STRING_KEYS.CONTINUE })}
</Button>
</Styled.ButtonRow>
</Styled.Content>
</Styled.Dialog>
);
};
const Styled: Record<string, AnyStyledComponent> = {};
Styled.Dialog = styled(Dialog)`
@media ${breakpoints.notMobile} {
--dialog-width: 30rem;
}
`;
Styled.Content = styled.div`
${layoutMixins.column}
gap: 1rem;
p {
border-radius: 0.5rem;
padding: 1rem;
background-color: var(--color-layer-1);
}
`;
Styled.Link = styled(Link)`
--link-color: var(--color-accent);
display: inline-block;
`;
Styled.ButtonRow = styled.div`
display: grid;
grid-template-columns: 1fr 2fr;
gap: 1rem;
`;
@@ -0,0 +1,361 @@
import { useMemo, useState } from 'react';
import styled, { AnyStyledComponent } from 'styled-components';
import { utils } from '@dydxprotocol/v4-client-js';
import { STRING_KEYS } from '@/constants/localization';
import { isMainnet } from '@/constants/networks';
import { PotentialMarketItem } from '@/constants/potentialMarkets';
import { useGovernanceVariables, useStringGetter, useTokenConfigs } from '@/hooks';
import { usePotentialMarkets } from '@/hooks/usePotentialMarkets';
import { layoutMixins } from '@/styles/layoutMixins';
import { Details } from '@/components/Details';
import { Dialog } from '@/components/Dialog';
import { Output, OutputType } from '@/components/Output';
import { Tag, TagType } from '@/components/Tag';
import { ToggleGroup } from '@/components/ToggleGroup';
import { MustBigNumber } from '@/lib/numbers';
type ElementProps = {
preventClose?: boolean;
setIsOpen?: (open: boolean) => void;
assetData?: PotentialMarketItem;
clobPairId?: number;
liquidityTier?: string;
};
export enum CodeToggleGroup {
CREATE_ORACLE = 'CREATE_ORACLE',
MSG_CREATE_PERPETUAL = 'MSG_CREATE_PERPETUAL',
MSG_CREATE_CLOB_PAIR = 'MSG_CREATE_CLOB_PAIR',
MSG_DELAY_MESSAGE = 'MSG_DELAY_MESSAGE',
MSG_SUBMIT_PROPOSAL = 'MSG_SUBMIT_PROPOSAL',
}
export const NewMarketMessageDetailsDialog = ({
assetData,
clobPairId,
liquidityTier,
preventClose,
setIsOpen,
}: ElementProps) => {
const [codeToggleGroup, setCodeToggleGroup] = useState(CodeToggleGroup.CREATE_ORACLE);
const { exchangeConfigs } = usePotentialMarkets();
const { baseAsset } = assetData ?? {};
const { newMarketProposal } = useGovernanceVariables();
const stringGetter = useStringGetter();
const { chainTokenDecimals, chainTokenLabel } = useTokenConfigs();
const initialDepositAmountDecimals = isMainnet ? 0 : chainTokenDecimals;
const exchangeConfig = useMemo(() => {
return baseAsset ? exchangeConfigs?.[baseAsset] : undefined;
}, [baseAsset]);
const ticker = useMemo(() => `${baseAsset}-USD`, [baseAsset]);
const toggleGroupItems: Parameters<typeof ToggleGroup>[0]['items'] = useMemo(() => {
return [
{
value: CodeToggleGroup.CREATE_ORACLE,
label: 'Create oracle market',
},
{
value: CodeToggleGroup.MSG_CREATE_PERPETUAL,
label: 'Msg create perpetual',
},
{
value: CodeToggleGroup.MSG_CREATE_CLOB_PAIR,
label: 'Msg create clobPair',
},
{
value: CodeToggleGroup.MSG_DELAY_MESSAGE,
label: 'Msg delay message',
},
{
value: CodeToggleGroup.MSG_SUBMIT_PROPOSAL,
label: 'Msg submit proposal',
},
];
}, []);
return (
<Dialog
isOpen
preventClose={preventClose}
setIsOpen={setIsOpen}
title={stringGetter({ key: STRING_KEYS.MESSAGE_DETAILS })}
>
<Styled.ProposedMessageDetails>
<Styled.Tabs
items={toggleGroupItems}
value={codeToggleGroup}
onValueChange={setCodeToggleGroup}
/>
{
{
[CodeToggleGroup.CREATE_ORACLE]: (
<Styled.Code>
<Styled.Details
layout="column"
items={[
{
key: 'id',
label: 'market_id',
value: `${clobPairId}`,
},
{
key: 'pair',
label: 'pair',
value: ticker,
},
{
key: 'min-exchanges',
label: 'min_exchanges',
value: `${assetData?.minExchanges}`,
},
{
key: 'min-price-change-ppm',
label: 'min_price_change_ppm',
value: `${assetData?.minPriceChangePpm}`,
},
]}
/>
<Styled.Text0>
exchange_config_json{' '}
{exchangeConfig && <Tag type={TagType.Number}>{exchangeConfig.length}</Tag>}
</Styled.Text0>
{'['}
{exchangeConfig?.map((exchange) => {
return (
<Styled.Code
key={exchange.exchangeName}
style={{ padding: 0, margin: 0, paddingLeft: '0.5rem' }}
>
{'{'}
{Object.keys(exchange).map((key) => (
<Styled.Line key={key}>
{key}: <span>{exchange[key as keyof typeof exchange]}</span>
</Styled.Line>
))}
{'},'}
</Styled.Code>
);
})}
{']'}
</Styled.Code>
),
[CodeToggleGroup.MSG_CREATE_PERPETUAL]: (
<Styled.Code>
<Styled.Details
layout="column"
items={[
{
key: 'perpetual_id',
label: 'perpetual_id',
value: `${clobPairId}`,
},
{
key: 'market_id',
label: 'market_id',
value: `${clobPairId}`,
},
{
key: 'ticker',
label: 'ticker',
value: ticker,
},
{
key: 'atomic_resolution',
label: 'atomic_resolution',
value: `${assetData?.atomicResolution}`,
},
{
key: 'default_funding_ppm',
label: 'default_funding_ppm',
value: '0',
},
{
key: 'liquidity_tier',
label: 'liquidity_tier',
value: liquidityTier,
},
]}
/>
</Styled.Code>
),
[CodeToggleGroup.MSG_CREATE_CLOB_PAIR]: (
<Styled.Code>
<Styled.Details
layout="column"
items={[
{
key: 'clob_id',
label: 'clob_id',
value: `${clobPairId}`,
},
{
key: 'perpetual_id',
label: 'perpetual_id',
value: `${clobPairId}`,
},
{
key: 'quantum_conversion_exponent',
label: 'quantum_conversion_exponent',
value: `${assetData?.quantumConversionExponent}`,
},
{
key: 'step_base_quantums',
label: 'step_base_quantums',
value: `${assetData?.stepBaseQuantum}`,
},
{
key: 'subticks_per_tick',
label: 'subticks_per_tick',
value: `${assetData?.subticksPerTick}`,
},
{
key: 'status',
label: 'status',
value: 'INITIALIZING',
},
]}
/>
</Styled.Code>
),
[CodeToggleGroup.MSG_DELAY_MESSAGE]: (
<Styled.Code>
<Styled.Details
layout="column"
items={[
{
key: 'delay_blocks',
label: 'delay_blocks',
value: (
<Output
type={OutputType.Asset}
value={newMarketProposal.delayBlocks}
fractionDigits={0}
/>
),
},
]}
/>
<div style={{ marginTop: '1rem' }}>MSG_UPDATE_CLOB_PAIR</div>
<Styled.Details
layout="column"
items={[
{
key: 'clob_id',
label: 'clob_id',
value: `${clobPairId}`,
},
{
key: 'perpetual_id',
label: 'perpetual_id',
value: `${clobPairId}`,
},
{
key: 'quantum_conversion_exponent',
label: 'quantum_conversion_exponent',
value: `${assetData?.quantumConversionExponent}`,
},
{
key: 'step_base_quantums',
label: 'step_base_quantums',
value: `${assetData?.stepBaseQuantum}`,
},
{
key: 'subticks_per_tick',
label: 'subticks_per_tick',
value: `${assetData?.subticksPerTick}`,
},
{
key: 'status',
label: 'status',
value: 'ACTIVE',
},
]}
/>
</Styled.Code>
),
[CodeToggleGroup.MSG_SUBMIT_PROPOSAL]: (
<Styled.Code>
<Styled.Text0>title: </Styled.Text0>
<Styled.Description>{utils.getGovAddNewMarketTitle(ticker)}</Styled.Description>
<Styled.Text0>initial_deposit_amount:</Styled.Text0>
<Styled.Description>
{
<Output
type={OutputType.Asset}
value={MustBigNumber(newMarketProposal.initialDepositAmount).div(
Number(`1e${chainTokenDecimals}`)
)}
fractionDigits={initialDepositAmountDecimals}
tag={chainTokenLabel}
/>
}
</Styled.Description>
<Styled.Text0>summary: </Styled.Text0>
<Styled.Description>
{utils.getGovAddNewMarketSummary(ticker, newMarketProposal.delayBlocks)}
</Styled.Description>
</Styled.Code>
),
}[codeToggleGroup]
}
</Styled.ProposedMessageDetails>
</Dialog>
);
};
const Styled: Record<string, AnyStyledComponent> = {};
Styled.Content = styled.div`
${layoutMixins.column}
gap: 0;
`;
Styled.ProposedMessageDetails = styled.div`
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
background-color: var(--color-layer-3);
margin-top: 1rem;
border-radius: 10px;
`;
Styled.Tabs = styled(ToggleGroup)`
overflow-x: auto;
`;
Styled.Text0 = styled.span`
color: var(--color-text-0);
`;
Styled.Code = styled.div`
background-color: var(--color-layer-1);
padding: 1rem;
border-radius: 10px;
font: var(--font-mini-book);
font-family: var(--fontFamily-monospace);
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0rem;
`;
Styled.Details = styled(Details)`
--details-item-height: 1.5rem;
`;
Styled.Line = styled.pre`
margin-left: 1rem;
`;
Styled.Description = styled.p`
margin-bottom: 1rem;
`;
@@ -0,0 +1,396 @@
import { FormEvent, useCallback, useMemo, useState } from 'react';
import styled, { AnyStyledComponent } from 'styled-components';
import { useDispatch } from 'react-redux';
import Long from 'long';
import { encodeJson } from '@dydxprotocol/v4-client-js';
import type { IndexedTx } from '@cosmjs/stargate';
import { AlertType } from '@/constants/alerts';
import { ButtonAction, ButtonSize, ButtonType } from '@/constants/buttons';
import { DialogTypes } from '@/constants/dialogs';
import { STRING_KEYS } from '@/constants/localization';
import { isMainnet } from '@/constants/networks';
import { NumberSign, TOKEN_DECIMALS } from '@/constants/numbers';
import { LIQUIDITY_TIERS, type PotentialMarketItem } from '@/constants/potentialMarkets';
import {
useAccountBalance,
useGovernanceVariables,
useStringGetter,
useSubaccount,
useTokenConfigs,
} from '@/hooks';
import { usePotentialMarkets } from '@/hooks/usePotentialMarkets';
import { formMixins } from '@/styles/formMixins';
import { layoutMixins } from '@/styles/layoutMixins';
import { AlertMessage } from '@/components/AlertMessage';
import { Button } from '@/components/Button';
import { DiffOutput } from '@/components/DiffOutput';
import { FormInput } from '@/components/FormInput';
import { Icon, IconName } from '@/components/Icon';
import { InputType } from '@/components/Input';
import { Output, OutputType } from '@/components/Output';
import { Tag } from '@/components/Tag';
import { WithDetailsReceipt } from '@/components/WithDetailsReceipt';
import { openDialog } from '@/state/dialogs';
import { MustBigNumber } from '@/lib/numbers';
import { log } from '@/lib/telemetry';
type NewMarketPreviewStepProps = {
assetData: PotentialMarketItem;
clobPairId: number;
liquidityTier: number;
onBack: () => void;
onSuccess: (hash: string) => void;
tickSizeDecimals: number;
};
export const NewMarketPreviewStep = ({
assetData,
clobPairId,
liquidityTier,
onBack,
onSuccess,
tickSizeDecimals,
}: NewMarketPreviewStepProps) => {
const { nativeTokenBalance } = useAccountBalance();
const dispatch = useDispatch();
const stringGetter = useStringGetter();
const { chainTokenDecimals, chainTokenLabel } = useTokenConfigs();
const [errorMessage, setErrorMessage] = useState();
const { exchangeConfigs } = usePotentialMarkets();
const { submitNewMarketProposal } = useSubaccount();
const { newMarketProposal } = useGovernanceVariables();
const initialDepositAmountBN = MustBigNumber(newMarketProposal.initialDepositAmount).div(
Number(`1e${chainTokenDecimals}`)
);
const initialDepositAmountDecimals = isMainnet ? 0 : chainTokenDecimals;
const initialDepositAmount = initialDepositAmountBN.toFixed(initialDepositAmountDecimals);
const [hasAcceptedTerms, setHasAcceptedTerms] = useState(false);
const { label, initialMarginFraction, maintenanceMarginFraction, impactNotional } =
LIQUIDITY_TIERS[liquidityTier as unknown as keyof typeof LIQUIDITY_TIERS];
const ticker = `${assetData.baseAsset}-USD`;
const alertMessage = useMemo(() => {
if (errorMessage) {
return {
type: AlertType.Error,
message: errorMessage,
};
}
if (nativeTokenBalance.lt(initialDepositAmountBN)) {
return {
type: AlertType.Error,
message: stringGetter({
key: STRING_KEYS.NOT_ENOUGH_BALANCE,
params: {
NUM_TOKENS_REQUIRED: initialDepositAmount,
NATIVE_TOKEN_DENOM: chainTokenLabel,
},
}),
};
}
return null;
}, [nativeTokenBalance, errorMessage]);
const isDisabled = alertMessage !== null;
return (
<Styled.Form
onSubmit={async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!hasAcceptedTerms) {
dispatch(
openDialog({
type: DialogTypes.NewMarketAgreement,
dialogProps: {
acceptTerms: () => setHasAcceptedTerms(true),
},
})
);
} else {
setErrorMessage(undefined);
try {
const tx = await submitNewMarketProposal({
id: clobPairId,
ticker,
priceExponent: assetData.priceExponent,
minPriceChange: assetData.minPriceChangePpm,
minExchanges: assetData.minExchanges,
exchangeConfigJson: JSON.stringify({
exchanges: exchangeConfigs?.[assetData.baseAsset],
}),
atomicResolution: assetData.atomicResolution,
liquidityTier: liquidityTier,
quantumConversionExponent: assetData.quantumConversionExponent,
stepBaseQuantums: Long.fromNumber(assetData.stepBaseQuantum),
subticksPerTick: assetData.subticksPerTick,
delayBlocks: newMarketProposal.delayBlocks,
});
if ((tx as IndexedTx)?.code === 0) {
const encodedTx = encodeJson(tx);
const parsedTx = JSON.parse(encodedTx);
const hash = parsedTx.hash.toUpperCase();
if (!hash) {
throw new Error('Invalid transaction hash');
}
onSuccess(hash);
} else {
throw new Error('Transaction failed to commit.');
}
} catch (error) {
log('NewMarketPreviewForm/submitNewMarketProposal', error);
setErrorMessage(error.message);
}
}
}}
>
<h2>
{stringGetter({ key: STRING_KEYS.CONFIRM_NEW_MARKET_PROPOSAL })}
<Styled.Balance>
{stringGetter({ key: STRING_KEYS.BALANCE })}:{' '}
<Output
type={OutputType.Number}
value={nativeTokenBalance}
fractionDigits={TOKEN_DECIMALS}
slotRight={<Styled.Tag>{chainTokenLabel}</Styled.Tag>}
/>
</Styled.Balance>
</h2>
<Styled.FormInput
disabled
label={stringGetter({ key: STRING_KEYS.MARKET })}
type={InputType.Text}
value={ticker}
/>
<Styled.WithDetailsReceipt
side="bottom"
detailItems={[
{
key: 'imf',
label: 'IMF',
tooltip: 'initial-margin-fraction',
value: (
<Output fractionDigits={2} type={OutputType.Number} value={initialMarginFraction} />
),
},
{
key: 'mmf',
label: 'MMF',
tooltip: 'maintenance-margin-fraction',
value: (
<Output
fractionDigits={2}
type={OutputType.Number}
value={maintenanceMarginFraction}
/>
),
},
{
key: 'impact-notional',
label: stringGetter({ key: STRING_KEYS.IMPACT_NOTIONAL }),
value: <Output type={OutputType.Fiat} value={impactNotional} />,
},
]}
>
<Styled.FormInput
disabled
label={stringGetter({ key: STRING_KEYS.LIQUIDITY_TIER })}
type={InputType.Text}
value={label}
/>
</Styled.WithDetailsReceipt>
<Styled.WithDetailsReceipt
detailItems={[
{
key: 'reference-price',
label: stringGetter({ key: STRING_KEYS.REFERENCE_PRICE }),
tooltip: 'reference-price',
value: (
<Output
type={OutputType.Fiat}
value={assetData.referencePrice}
fractionDigits={tickSizeDecimals}
/>
),
},
{
key: 'message-details',
label: stringGetter({ key: STRING_KEYS.MESSAGE_DETAILS }),
value: (
<Styled.Button
action={ButtonAction.Navigation}
size={ButtonSize.Small}
onClick={() =>
dispatch(
openDialog({
type: DialogTypes.NewMarketMessageDetails,
dialogProps: { assetData, clobPairId, liquidityTier },
})
)
}
>
{stringGetter({ key: STRING_KEYS.VIEW_DETAILS })}
</Styled.Button>
),
},
{
key: 'required-balance',
label: (
<span>
{stringGetter({ key: STRING_KEYS.REQUIRED_BALANCE })} <Tag>{chainTokenLabel}</Tag>
</span>
),
value: (
<Output
type={OutputType.Number}
value={initialDepositAmount}
fractionDigits={initialDepositAmountDecimals}
slotRight={
<>
{'+ '}
<Styled.Icon
$hasError={nativeTokenBalance?.lt(initialDepositAmountBN)}
iconName={
nativeTokenBalance?.gt(initialDepositAmountBN)
? IconName.CheckCircle
: IconName.CautionCircle
}
/>
</>
}
/>
),
},
{
key: 'wallet-balance',
label: (
<span>
{stringGetter({ key: STRING_KEYS.WALLET_BALANCE })} <Tag>{chainTokenLabel}</Tag>
</span>
),
value: (
<DiffOutput
withDiff
hasInvalidNewValue={isDisabled}
sign={NumberSign.Negative}
fractionDigits={TOKEN_DECIMALS}
type={OutputType.Number}
value={nativeTokenBalance.isZero() ? undefined : nativeTokenBalance}
newValue={nativeTokenBalance.minus(initialDepositAmountBN)}
/>
),
},
]}
>
<div />
</Styled.WithDetailsReceipt>
{alertMessage && (
<AlertMessage type={alertMessage.type}>{alertMessage.message} </AlertMessage>
)}
<Styled.ButtonRow>
<Button onClick={onBack}>{stringGetter({ key: STRING_KEYS.BACK })}</Button>
<Button type={ButtonType.Submit} action={ButtonAction.Primary} state={{ isDisabled }}>
{hasAcceptedTerms
? stringGetter({ key: STRING_KEYS.PROPOSE_NEW_MARKET })
: stringGetter({ key: STRING_KEYS.ACKNOWLEDGE_TERMS })}
</Button>
</Styled.ButtonRow>
<Styled.Disclaimer>
{stringGetter({
key: STRING_KEYS.PROPOSAL_DISCLAIMER,
params: {
NUM_TOKENS_REQUIRED: initialDepositAmount,
NATIVE_TOKEN_DENOM: chainTokenLabel,
},
})}
</Styled.Disclaimer>
</Styled.Form>
);
};
const Styled: Record<string, AnyStyledComponent> = {};
Styled.Form = styled.form`
${formMixins.transfersForm}
${layoutMixins.stickyArea0}
--stickyArea0-background: transparent;
h2 {
${layoutMixins.row}
justify-content: space-between;
margin: 0;
font: var(--font-large-medium);
color: var(--color-text-2);
}
`;
Styled.Balance = styled.span`
${layoutMixins.inlineRow}
font: var(--font-small-book);
margin-top: 0.125rem;
output {
margin-left: 0.5ch;
}
`;
Styled.Tag = styled(Tag)`
margin-left: 0.5ch;
`;
Styled.FormInput = styled(FormInput)`
input {
font-size: 1rem;
}
`;
Styled.Icon = styled(Icon)<{ $hasError?: boolean }>`
margin-left: 0.5ch;
${({ $hasError }) => ($hasError ? 'color: var(--color-error);' : 'color: var(--color-success);')}
`;
Styled.WithDetailsReceipt = styled(WithDetailsReceipt)`
--details-item-fontSize: 1rem;
`;
Styled.CheckboxContainer = styled.div`
display: flex;
flex-direction: row;
padding: 1rem;
align-items: center;
`;
Styled.Disclaimer = styled.div<{ textAlign?: string }>`
font: var(--font-small);
color: var(--color-text-0);
text-align: center;
margin-left: 0.5ch;
${({ textAlign }) => textAlign && `text-align: ${textAlign};`}
`;
Styled.ButtonRow = styled.div`
display: grid;
grid-template-columns: 1fr 2fr;
gap: 1rem;
width: 100%;
`;
Styled.Button = styled(Button)`
--button-padding: 0;
`;
@@ -0,0 +1,466 @@
import { FormEvent, useEffect, useMemo, useState } from 'react';
import styled, { AnyStyledComponent } from 'styled-components';
import { Root, Item } from '@radix-ui/react-radio-group';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { OnboardingState } from '@/constants/account';
import { AlertType } from '@/constants/alerts';
import { ButtonAction, ButtonShape, ButtonSize, ButtonType } from '@/constants/buttons';
import { DialogTypes } from '@/constants/dialogs';
import { STRING_KEYS } from '@/constants/localization';
import { isMainnet } from '@/constants/networks';
import { TOKEN_DECIMALS } from '@/constants/numbers';
import {
LIQUIDITY_TIERS,
NUM_ORACLES_TO_QUALIFY_AS_SAFE,
type PotentialMarketItem,
} from '@/constants/potentialMarkets';
import {
useAccountBalance,
useBreakpoints,
useGovernanceVariables,
useStringGetter,
useTokenConfigs,
} from '@/hooks';
import { usePotentialMarkets } from '@/hooks/usePotentialMarkets';
import { breakpoints } from '@/styles';
import { formMixins } from '@/styles/formMixins';
import { layoutMixins } from '@/styles/layoutMixins';
import { AlertMessage } from '@/components/AlertMessage';
import { Button } from '@/components/Button';
import { Details } from '@/components/Details';
import { Icon, IconName } from '@/components/Icon';
import { Output, OutputType } from '@/components/Output';
import { Tag } from '@/components/Tag';
import { SearchSelectMenu } from '@/components/SearchSelectMenu';
import { WithReceipt } from '@/components/WithReceipt';
import { OnboardingTriggerButton } from '@/views/dialogs/OnboardingTriggerButton';
import { getOnboardingState } from '@/state/accountSelectors';
import { openDialog } from '@/state/dialogs';
import { getMarketIds } from '@/state/perpetualsSelectors';
import { isTruthy } from '@/lib/isTruthy';
import { MustBigNumber } from '@/lib/numbers';
type NewMarketSelectionStepProps = {
assetToAdd?: PotentialMarketItem;
clobPairId?: number;
setAssetToAdd: (assetToAdd?: PotentialMarketItem) => void;
onConfirmMarket: () => void;
liquidityTier?: number;
setLiquidityTier: (liquidityTier?: number) => void;
tickSizeDecimals: number;
};
export const NewMarketSelectionStep = ({
assetToAdd,
clobPairId,
setAssetToAdd,
onConfirmMarket,
liquidityTier,
setLiquidityTier,
tickSizeDecimals,
}: NewMarketSelectionStepProps) => {
const dispatch = useDispatch();
const { nativeTokenBalance } = useAccountBalance();
const onboardingState = useSelector(getOnboardingState);
const isDisconnected = onboardingState === OnboardingState.Disconnected;
const { isMobile } = useBreakpoints();
const marketIds = useSelector(getMarketIds, shallowEqual);
const { chainTokenDecimals, chainTokenLabel } = useTokenConfigs();
const { potentialMarkets, exchangeConfigs } = usePotentialMarkets();
const stringGetter = useStringGetter();
const { newMarketProposal } = useGovernanceVariables();
const initialDepositAmountBN = MustBigNumber(newMarketProposal.initialDepositAmount).div(
Number(`1e${chainTokenDecimals}`)
);
const initialDepositAmountDecimals = isMainnet ? 0 : chainTokenDecimals;
const initialDepositAmount = initialDepositAmountBN.toFixed(initialDepositAmountDecimals);
const [tempLiquidityTier, setTempLiquidityTier] = useState<number>();
const [canModifyLiqTier, setCanModifyLiqTier] = useState(false);
const alertMessage = useMemo(() => {
if (nativeTokenBalance.lt(initialDepositAmountBN)) {
return {
type: AlertType.Warning,
message: stringGetter({
key: STRING_KEYS.NOT_ENOUGH_BALANCE,
params: {
NUM_TOKENS_REQUIRED: initialDepositAmount,
NATIVE_TOKEN_DENOM: chainTokenLabel,
},
}),
};
}
return null;
}, [nativeTokenBalance, stringGetter]);
useEffect(() => {
if (assetToAdd) {
setTempLiquidityTier(assetToAdd.liquidityTier);
setLiquidityTier(assetToAdd.liquidityTier);
}
}, [assetToAdd]);
const filteredPotentialMarkets = useMemo(() => {
return potentialMarkets?.filter(
({ baseAsset, numOracles }) =>
exchangeConfigs?.[baseAsset] !== undefined &&
Number(numOracles) >= NUM_ORACLES_TO_QUALIFY_AS_SAFE &&
!marketIds.includes(`${baseAsset}-USD`)
);
}, [exchangeConfigs, potentialMarkets, marketIds]);
return (
<Styled.Form
onSubmit={(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (canModifyLiqTier) {
setLiquidityTier(tempLiquidityTier);
setCanModifyLiqTier(false);
} else {
onConfirmMarket();
}
}}
>
<h2>
{stringGetter({ key: STRING_KEYS.ADD_A_MARKET })}
<Styled.Balance>
{stringGetter({ key: STRING_KEYS.BALANCE })}:{' '}
<Output
type={OutputType.Number}
value={nativeTokenBalance}
fractionDigits={TOKEN_DECIMALS}
slotRight={<Styled.Tag>{chainTokenLabel}</Styled.Tag>}
/>
</Styled.Balance>
</h2>
<SearchSelectMenu
items={[
{
group: 'markets',
groupLabel: stringGetter({ key: STRING_KEYS.MARKETS }),
items:
filteredPotentialMarkets?.map((potentialMarket: PotentialMarketItem) => ({
value: potentialMarket.baseAsset,
label: potentialMarket?.assetName ?? potentialMarket.baseAsset,
tag: `${potentialMarket.baseAsset}-USD`,
onSelect: () => {
setAssetToAdd(potentialMarket);
},
})) ?? [],
},
]}
label={stringGetter({ key: STRING_KEYS.MARKETS })}
>
{assetToAdd ? (
<Styled.SelectedAsset>
{assetToAdd?.assetName ?? assetToAdd.baseAsset} <Tag>{assetToAdd?.baseAsset}-USD</Tag>
</Styled.SelectedAsset>
) : (
'e.g. "BTC-USD"'
)}
</SearchSelectMenu>
{assetToAdd && (
<>
<div>{stringGetter({ key: STRING_KEYS.POPULATED_DETAILS })}</div>
<div>
<Styled.Root value={tempLiquidityTier} onValueChange={setTempLiquidityTier}>
<Styled.Header>
{stringGetter({ key: STRING_KEYS.LIQUIDITY_TIER })}
<Styled.ButtonRow>
<Button
shape={ButtonShape.Pill}
onClick={() => {
if (canModifyLiqTier) {
setTempLiquidityTier(liquidityTier);
}
setCanModifyLiqTier(!canModifyLiqTier);
}}
>
{canModifyLiqTier ? (
stringGetter({ key: STRING_KEYS.CANCEL })
) : (
<>
{stringGetter({ key: STRING_KEYS.MODIFY })}{' '}
<Icon iconName={IconName.Pencil} />
</>
)}
</Button>
{canModifyLiqTier && (
<Button
shape={ButtonShape.Pill}
action={ButtonAction.Primary}
onClick={() => {
setLiquidityTier(tempLiquidityTier);
setCanModifyLiqTier(false);
}}
>
{stringGetter({ key: STRING_KEYS.SAVE })}
</Button>
)}
</Styled.ButtonRow>
</Styled.Header>
{Object.keys(LIQUIDITY_TIERS).map((tier) => {
const { maintenanceMarginFraction, impactNotional, label, initialMarginFraction } =
LIQUIDITY_TIERS[tier as unknown as keyof typeof LIQUIDITY_TIERS];
return (
<Styled.LiquidityTierRadioButton
key={tier}
value={Number(tier)}
selected={Number(tier) === tempLiquidityTier}
disabled={!canModifyLiqTier}
>
<Styled.Header style={{ marginLeft: '1rem' }}>
{label}
{Number(tier) === assetToAdd?.liquidityTier && (
<Tag style={{ marginLeft: '0.5ch' }}>
{stringGetter({ key: STRING_KEYS.RECOMMENDED })}
</Tag>
)}
</Styled.Header>
<Styled.Details
layout={isMobile ? 'grid' : 'rowColumns'}
withSeparators={!isMobile}
items={[
{
key: 'imf',
label: 'IMF',
tooltip: 'initial-margin-fraction',
value: (
<Output
fractionDigits={2}
type={OutputType.Number}
value={initialMarginFraction}
/>
),
},
{
key: 'mmf',
label: 'MMF',
tooltip: 'maintenance-margin-fraction',
value: (
<Output
fractionDigits={2}
type={OutputType.Number}
value={maintenanceMarginFraction}
/>
),
},
{
key: 'impact-notional',
label: stringGetter({ key: STRING_KEYS.IMPACT_NOTIONAL }),
value: <Output type={OutputType.Fiat} value={impactNotional} />,
},
]}
/>
</Styled.LiquidityTierRadioButton>
);
})}
</Styled.Root>
</div>
</>
)}
{alertMessage && (
<AlertMessage type={alertMessage.type}>{alertMessage.message} </AlertMessage>
)}
<WithReceipt
slotReceipt={
<Styled.ReceiptDetails
items={[
assetToAdd && {
key: 'reference-price',
label: stringGetter({ key: STRING_KEYS.REFERENCE_PRICE }),
tooltip: 'reference-price',
value: (
<Output
type={OutputType.Fiat}
value={assetToAdd.referencePrice}
fractionDigits={tickSizeDecimals}
/>
),
},
assetToAdd && {
key: 'message-details',
label: stringGetter({ key: STRING_KEYS.MESSAGE_DETAILS }),
value: (
<Styled.Button
action={ButtonAction.Navigation}
size={ButtonSize.Small}
onClick={() =>
dispatch(
openDialog({
type: DialogTypes.NewMarketMessageDetails,
dialogProps: { assetData: assetToAdd, clobPairId, liquidityTier },
})
)
}
>
{stringGetter({ key: STRING_KEYS.VIEW_DETAILS })}
</Styled.Button>
),
},
{
key: 'dydx-required',
label: (
<span>
{stringGetter({ key: STRING_KEYS.REQUIRED_BALANCE })}{' '}
<Tag>{chainTokenLabel}</Tag>
</span>
),
value: (
<Styled.Disclaimer>
{stringGetter({
key: STRING_KEYS.OR_MORE,
params: {
NUMBER: (
<Styled.Output
useGrouping
type={OutputType.Number}
value={initialDepositAmountBN}
fractionDigits={initialDepositAmountDecimals}
/>
),
},
})}
</Styled.Disclaimer>
),
},
].filter(isTruthy)}
/>
}
>
{isDisconnected ? (
<OnboardingTriggerButton />
) : (
<Button
type={ButtonType.Submit}
state={{ isDisabled: !assetToAdd || !liquidityTier === undefined || !clobPairId }}
action={ButtonAction.Primary}
>
{canModifyLiqTier
? stringGetter({ key: STRING_KEYS.SAVE })
: stringGetter({ key: STRING_KEYS.PREVIEW_MARKET_PROPOSAL })}
</Button>
)}
</WithReceipt>
</Styled.Form>
);
};
const Styled: Record<string, AnyStyledComponent> = {};
Styled.Form = styled.form`
${formMixins.transfersForm}
${layoutMixins.stickyArea0}
--stickyArea0-background: transparent;
h2 {
${layoutMixins.row}
justify-content: space-between;
margin: 0;
font: var(--font-large-medium);
color: var(--color-text-2);
}
`;
Styled.Balance = styled.span`
${layoutMixins.inlineRow}
font: var(--font-small-book);
margin-top: 0.125rem;
output {
margin-left: 0.5ch;
}
`;
Styled.Tag = styled(Tag)`
margin-left: 0.5ch;
`;
Styled.SelectedAsset = styled.span`
color: var(--color-text-2);
`;
Styled.Disclaimer = styled.div`
color: var(--color-text-0);
margin-left: 0.5ch;
`;
Styled.Header = styled.div`
display: flex;
flex: 1;
align-items: center;
color: var(--color-text-2);
font: var(--font-base-medium);
justify-content: space-between;
`;
Styled.ButtonRow = styled.div`
display: flex;
flex-direction: row;
gap: 0.5rem;
button {
min-width: 80px;
}
`;
Styled.Root = styled(Root)`
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
border-radius: 10px;
border: 1px solid var(--color-layer-6);
background-color: var(--color-layer-4);
`;
Styled.LiquidityTierRadioButton = styled(Item)<{ selected?: boolean }>`
display: flex;
flex-direction: column;
border-radius: 0.625rem;
border: 1px solid var(--color-layer-6);
padding: 1rem 0;
font: var(--font-mini-book);
${({ selected }) => selected && 'background-color: var(--color-layer-2)'}
`;
Styled.Details = styled(Details)`
margin-top: 0.5rem;
padding: 0;
dt {
text-align: left;
}
@media ${breakpoints.mobile} {
padding: 0 1rem;
dd {
margin-bottom: 0.5rem;
}
}
`;
Styled.ReceiptDetails = styled(Details)`
padding: 0.375rem 0.75rem 0.25rem;
`;
Styled.Output = styled(Output)`
display: inline-block;
`;
Styled.Button = styled(Button)`
--button-padding: 0;
`;
@@ -0,0 +1,81 @@
import styled, { AnyStyledComponent } from 'styled-components';
import { ButtonAction, ButtonType } from '@/constants/buttons';
import { STRING_KEYS } from '@/constants/localization';
import { useStringGetter } from '@/hooks';
import { LinkOutIcon } from '@/icons';
import { Button } from '@/components/Button';
import { Icon, IconName } from '@/components/Icon';
type NewMarketSuccessStepProps = {
href: string;
};
export const NewMarketSuccessStep = ({ href }: NewMarketSuccessStepProps) => {
const stringGetter = useStringGetter();
return (
<Styled.ProposalSent>
<Styled.OuterCircle>
<Styled.InnerCircle>
<Icon iconName={IconName.Check} />
</Styled.InnerCircle>
</Styled.OuterCircle>
<h2>{stringGetter({ key: STRING_KEYS.SUBMITTED_PROPOSAL })}</h2>
<span>{stringGetter({ key: STRING_KEYS.PROPOSAL_SUBMISSION_SUCCESSFUL })}</span>
<Button type={ButtonType.Link} href={href} action={ButtonAction.Primary}>
{stringGetter({ key: STRING_KEYS.VIEW_PROPOSAL })}
<LinkOutIcon />
</Button>
</Styled.ProposalSent>
);
};
const Styled: Record<string, AnyStyledComponent> = {};
Styled.ProposalSent = styled.div`
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 1rem;
&& {
h2 {
margin: 0 1rem;
}
}
`;
Styled.OuterCircle = styled.div`
width: 5.25rem;
height: 5.25rem;
min-width: 5.25rem;
height: 5.25rem;
border-radius: 50%;
background-color: var(--color-gradient-positive);
display: flex;
align-items: center;
justify-content: center;
`;
Styled.InnerCircle = styled.div`
width: 2rem;
height: 2rem;
min-width: 2rem;
height: 2rem;
border-radius: 50%;
background-color: var(--color-success);
display: flex;
align-items: center;
justify-content: center;
svg {
color: var(--color-layer-2);
}
`;
+80
View File
@@ -0,0 +1,80 @@
import { useMemo, useState } from 'react';
import styled, { AnyStyledComponent } from 'styled-components';
import { TOKEN_DECIMALS } from '@/constants/numbers';
import { type PotentialMarketItem } from '@/constants/potentialMarkets';
import { useNextClobPairId, useURLConfigs } from '@/hooks';
import { usePotentialMarkets } from '@/hooks/usePotentialMarkets';
import { LoadingSpace } from '@/components/Loading/LoadingSpinner';
import { NewMarketSelectionStep } from './NewMarketSelectionStep';
import { NewMarketPreviewStep } from './NewMarketPreviewStep';
import { NewMarketSuccessStep } from './NewMarketSuccessStep';
enum NewMarketFormStep {
SELECTION,
PREVIEW,
SUCCESS,
}
export const NewMarketForm = () => {
const [step, setStep] = useState(NewMarketFormStep.SELECTION);
const [assetToAdd, setAssetToAdd] = useState<PotentialMarketItem>();
const [liquidityTier, setLiquidityTier] = useState<number>();
const [proposalTxHash, setProposalTxHash] = useState<string>();
const { mintscan: mintscanTxUrl } = useURLConfigs();
const { nextAvailableClobPairId } = useNextClobPairId();
const { hasPotentialMarketsData } = usePotentialMarkets();
const tickSizeDecimals = useMemo(() => {
if (!assetToAdd) return TOKEN_DECIMALS;
const p = Math.floor(Math.log(Number(assetToAdd.referencePrice)));
return Math.abs(p - 3);
}, [assetToAdd]);
if (!hasPotentialMarketsData || !nextAvailableClobPairId) {
return <Styled.LoadingSpace id="new-market-form" />;
}
if (NewMarketFormStep.SUCCESS === step && proposalTxHash) {
return <NewMarketSuccessStep href={mintscanTxUrl.replace('{tx_hash}', proposalTxHash)} />;
}
if (NewMarketFormStep.PREVIEW === step) {
if (assetToAdd && liquidityTier && nextAvailableClobPairId) {
return (
<NewMarketPreviewStep
assetData={assetToAdd}
clobPairId={nextAvailableClobPairId}
liquidityTier={liquidityTier}
onBack={() => setStep(NewMarketFormStep.SELECTION)}
onSuccess={(hash: string) => {
setProposalTxHash(hash);
setStep(NewMarketFormStep.SUCCESS);
}}
tickSizeDecimals={tickSizeDecimals}
/>
);
}
}
return (
<NewMarketSelectionStep
onConfirmMarket={() => setStep(NewMarketFormStep.PREVIEW)}
assetToAdd={assetToAdd}
clobPairId={nextAvailableClobPairId}
setAssetToAdd={setAssetToAdd}
liquidityTier={liquidityTier}
setLiquidityTier={setLiquidityTier}
tickSizeDecimals={tickSizeDecimals}
/>
);
};
const Styled: Record<string, AnyStyledComponent> = {};
Styled.LoadingSpace = styled(LoadingSpace)`
min-height: 18.75rem;
`;
+5 -4
View File
@@ -11,6 +11,7 @@ import { setSelectedTradeLayout } from '@/state/layout';
import { getAssets } from '@/state/assetsSelectors';
import { getPerpetualMarkets } from '@/state/perpetualsSelectors';
import { Asset, PerpetualMarket } from '@/constants/abacus';
enum ThemeItems {
SetClassicTheme = 'SetDefaultTheme',
@@ -44,7 +45,7 @@ export const useGlobalCommands = (): MenuConfig<string, string> => {
const joinedPerpetualMarketsAndAssets = Object.values(allPerpetualMarkets).map((market) => ({
...market,
...allAssets[market?.assetId],
}));
})) as Array<PerpetualMarket & Asset>;
return [
{
@@ -129,10 +130,10 @@ export const useGlobalCommands = (): MenuConfig<string, string> => {
{
value: NavItems.NavigateToMarket,
label: 'Navigate to Market',
subitems: joinedPerpetualMarketsAndAssets.map(({ market = '', name = '', id = '' }) => ({
value: market,
subitems: joinedPerpetualMarketsAndAssets.map(({ market, name, id }) => ({
value: market ?? '',
slotBefore: <AssetIcon symbol={id} />,
label: name,
label: name ?? '',
tag: id,
onSelect: () => navigate(`/trade/${market}`),
})),