From eba2c6b87604e7ace85a3abc3e64f93d3e970746 Mon Sep 17 00:00:00 2001 From: fionn223 <161711975+fionn223@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:00:39 +0000 Subject: [PATCH 01/13] feat: add Liquidation Rebates banner and update No Fee December string (#2002) --- package.json | 4 +-- pnpm-lock.yaml | 8 ++--- public/liquidation.png | Bin 0 -> 258753 bytes src/pages/markets/MarketsBanners.tsx | 2 +- src/views/MarketsStats.tsx | 45 ++++++--------------------- 5 files changed, 17 insertions(+), 42 deletions(-) create mode 100644 public/liquidation.png diff --git a/package.json b/package.json index 2fb29ddcf0..f37b1bb46d 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "@cosmjs/tendermint-rpc": "^0.32.1", "@datadog/browser-logs": "^5.23.3", "@dydxprotocol/v4-client-js": "3.2.0", - "@dydxprotocol/v4-localization": "1.1.361", + "@dydxprotocol/v4-localization": "1.1.362", "@dydxprotocol/v4-proto": "^7.0.0-dev.0", "@emotion/is-prop-valid": "^1.3.0", "@hugocxl/react-to-image": "^0.0.9", @@ -234,4 +234,4 @@ "includeClassNames": true } } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8132781cb3..7589684e50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,8 +33,8 @@ dependencies: specifier: 3.2.0 version: 3.2.0 '@dydxprotocol/v4-localization': - specifier: 1.1.361 - version: 1.1.361 + specifier: 1.1.362 + version: 1.1.362 '@dydxprotocol/v4-proto': specifier: ^7.0.0-dev.0 version: 7.0.5 @@ -1690,8 +1690,8 @@ packages: - utf-8-validate dev: false - /@dydxprotocol/v4-localization@1.1.361: - resolution: {integrity: sha512-Tq7KBxZ/H6RZgGwM3VQw7euQ7hgVR4FTymPYbU5AYV5pRXdLee5IkYKqkbc/SJBUsDuMaMNQ8WEARdeEaam5HA==} + /@dydxprotocol/v4-localization@1.1.362: + resolution: {integrity: sha512-mkm+X+LRBjvdB85ws9egJxNwosSeO53dgvftHCdRA/g0sZ7+cqIu7sFv+3T7SZ3YFX9LCHbpugEKxgqtjVPDyg==} dev: false /@dydxprotocol/v4-proto@7.0.5: diff --git a/public/liquidation.png b/public/liquidation.png new file mode 100644 index 0000000000000000000000000000000000000000..306cb34c0ee2d87edeee8626bc672ca3331bbbdc GIT binary patch literal 258753 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

kd1&Scx=T@iM&tkRWL}Tm{HLytM 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 literal 0 HcmV?d00001 diff --git a/src/pages/markets/MarketsBanners.tsx b/src/pages/markets/MarketsBanners.tsx index 19a56edb77..e04b60a000 100644 --- a/src/pages/markets/MarketsBanners.tsx +++ b/src/pages/markets/MarketsBanners.tsx @@ -179,7 +179,7 @@ export const MarketsBanners = ({ {stringGetter({ key: STRING_KEYS.NO_FEE_NOVEMBER_BANNER_TITLE_ACCENT })} {' '} - {stringGetter({ key: STRING_KEYS.NO_FEE_NOVEMBER_BANNER_TITLE })} + {stringGetter({ key: STRING_KEYS.NO_FEE_DECEMBER_BANNER_TITLE })} {stringGetter({ key: STRING_KEYS.NO_FEE_NOVEMBER_BANNER_CTA })} → diff --git a/src/views/MarketsStats.tsx b/src/views/MarketsStats.tsx index b6cee44eaa..ecda55683d 100644 --- a/src/views/MarketsStats.tsx +++ b/src/views/MarketsStats.tsx @@ -50,32 +50,21 @@ export const MarketsStats = (props: MarketsStatsProps) => { )} - <$TradingLeaguesBanner> - Trading League trophy -

-
- - {stringGetter({ key: STRING_KEYS.TRADING_LEAGUES_BANNER_TITLE })}{' '} - <$ActiveTag>{stringGetter({ key: STRING_KEYS.ACTIVE })} - - - {stringGetter({ key: STRING_KEYS.TRADING_LEAGUES_BANNER_SUBTITLE })} - -
+ <$LiquidationRebatesBanner> +
+ + {stringGetter({ key: STRING_KEYS.LIQUIDATION_REBATES_BANNER_TITLE })} +
- + ); }; @@ -99,9 +88,8 @@ const $SectionHeader = styled.div` } `; -const $TradingLeaguesBanner = styled.div` - background-image: - linear-gradient(rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7)), url('/trading-league-banner-bg.png'); +const $LiquidationRebatesBanner = styled.div` + background-image: url('/liquidation.png'); background-position: center; background-size: cover; background-repeat: no-repeat; @@ -111,16 +99,3 @@ const $TradingLeaguesBanner = styled.div` display: grid; border-radius: 0.625rem; `; - -const $ActiveTag = styled.span` - border-radius: 0.5rem; - border: 1px solid; - border-color: var(--color-positive); - background-color: color-mix(in srgb, var(--color-positive) 12%, transparent); - padding: 0.1875rem 0.375rem; - color: var(--color-positive); - font: var(--font-mini-bold); - vertical-align: middle; - display: inline-block; - transform: translateY(-2px); -`; From 0d8532a10f10dfecdb1b3c60549f3ea6689bbdad Mon Sep 17 00:00:00 2001 From: Luka Buzaladze Date: Wed, 3 Dec 2025 12:56:03 -0500 Subject: [PATCH 02/13] fix: update liq competition to be monthly (#2004) --- package.json | 2 +- pnpm-lock.yaml | 9 ++++----- .../token/CompetitionIncentivesPanel.tsx | 20 +++++++++---------- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index f37b1bb46d..e8f6bada3a 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "@cosmjs/tendermint-rpc": "^0.32.1", "@datadog/browser-logs": "^5.23.3", "@dydxprotocol/v4-client-js": "3.2.0", - "@dydxprotocol/v4-localization": "1.1.362", + "@dydxprotocol/v4-localization": "1.1.363", "@dydxprotocol/v4-proto": "^7.0.0-dev.0", "@emotion/is-prop-valid": "^1.3.0", "@hugocxl/react-to-image": "^0.0.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7589684e50..065130d101 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,8 +33,8 @@ dependencies: specifier: 3.2.0 version: 3.2.0 '@dydxprotocol/v4-localization': - specifier: 1.1.362 - version: 1.1.362 + specifier: 1.1.363 + version: 1.1.363 '@dydxprotocol/v4-proto': specifier: ^7.0.0-dev.0 version: 7.0.5 @@ -1217,7 +1217,6 @@ packages: /@cosmjs/crypto@0.27.1: resolution: {integrity: sha512-vbcxwSt99tIYJg8Spp00wc3zx72qx+pY3ozGuBN8gAvySnagK9dQ/jHwtWQWdammmdD6oW+75WfIHZ+gNa+Ybg==} - deprecated: This uses elliptic for cryptographic operations, which contains several security-relevant bugs. To what degree this affects your application is something you need to carefully investigate. See https://github.com/cosmos/cosmjs/issues/1708 for further pointers. Starting with version 0.34.0 the cryptographic library has been replaced. However, private keys might still be at risk. dependencies: '@cosmjs/encoding': 0.27.1 '@cosmjs/math': 0.27.1 @@ -1690,8 +1689,8 @@ packages: - utf-8-validate dev: false - /@dydxprotocol/v4-localization@1.1.362: - resolution: {integrity: sha512-mkm+X+LRBjvdB85ws9egJxNwosSeO53dgvftHCdRA/g0sZ7+cqIu7sFv+3T7SZ3YFX9LCHbpugEKxgqtjVPDyg==} + /@dydxprotocol/v4-localization@1.1.363: + resolution: {integrity: sha512-5/BBBp3lCXLc5fVLmfmIdldV5Of3UIT/GmPTSGHk5L4t0OVmVyiaHhQY+8OnVJMeLxNpZZMtYfMdiZpefQ/JPA==} dev: false /@dydxprotocol/v4-proto@7.0.5: diff --git a/src/pages/token/CompetitionIncentivesPanel.tsx b/src/pages/token/CompetitionIncentivesPanel.tsx index 2343b68056..9dd110be9c 100644 --- a/src/pages/token/CompetitionIncentivesPanel.tsx +++ b/src/pages/token/CompetitionIncentivesPanel.tsx @@ -36,14 +36,14 @@ export const CompetitionIncentivesPanel = () => { const September2025RewardsPanel = () => { const stringGetter = useStringGetter(); - - const week = Math.floor((new Date().getUTCDate() - 1) / 7) + 1; - + const now = new Date(); + // December 2025 is the first month (Month 1) + // Calculate months difference from Dec 2025, and add 1 to make it "Month 1" + const monthNumber = (now.getUTCFullYear() - 2025) * 12 + (now.getUTCMonth() - 11) + 1; + // For endTime, set to last ms of current UTC month const endTime = (() => { - const date = new Date(); - date.setUTCDate((week - 1) * 7); - date.setUTCHours(23, 59, 59, 999); - date.setUTCDate(date.getUTCDate() + 7); + const date = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0, 0)); // first of next month UTC + date.setTime(date.getTime() - 1); return date.toISOString(); })(); @@ -86,8 +86,8 @@ const September2025RewardsPanel = () => {
{stringGetter({ - key: STRING_KEYS.WEEK_COUNTDOWN, - params: { WEEK: week }, + key: STRING_KEYS.MONTH_COUNTDOWN, + params: { MONTH: monthNumber }, })} :
@@ -124,7 +124,7 @@ const Sept2025RewardsPanel = () => { })} slotTrigger={
- {stringGetter({ key: STRING_KEYS.WEEKLY_PRIZE })} + {stringGetter({ key: STRING_KEYS.MONTHLY_PRIZE })}
} From 3ffaceb3c2e719ab82ced96a99f1ceb936d4ab94 Mon Sep 17 00:00:00 2001 From: devin Date: Fri, 5 Dec 2025 13:06:42 -0500 Subject: [PATCH 03/13] feat: funding comparison link in menu (#2005) --- package.json | 2 +- pnpm-lock.yaml | 9 +++++---- public/configs/v1/env.json | 4 ++++ src/hooks/useURLConfigs.ts | 2 ++ src/layout/Header/HeaderDesktop.tsx | 9 ++++++++- 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index e8f6bada3a..73b5d83606 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "@cosmjs/tendermint-rpc": "^0.32.1", "@datadog/browser-logs": "^5.23.3", "@dydxprotocol/v4-client-js": "3.2.0", - "@dydxprotocol/v4-localization": "1.1.363", + "@dydxprotocol/v4-localization": "1.1.364", "@dydxprotocol/v4-proto": "^7.0.0-dev.0", "@emotion/is-prop-valid": "^1.3.0", "@hugocxl/react-to-image": "^0.0.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 065130d101..ecc09710d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,8 +33,8 @@ dependencies: specifier: 3.2.0 version: 3.2.0 '@dydxprotocol/v4-localization': - specifier: 1.1.363 - version: 1.1.363 + specifier: 1.1.364 + version: 1.1.364 '@dydxprotocol/v4-proto': specifier: ^7.0.0-dev.0 version: 7.0.5 @@ -1217,6 +1217,7 @@ packages: /@cosmjs/crypto@0.27.1: resolution: {integrity: sha512-vbcxwSt99tIYJg8Spp00wc3zx72qx+pY3ozGuBN8gAvySnagK9dQ/jHwtWQWdammmdD6oW+75WfIHZ+gNa+Ybg==} + deprecated: This uses elliptic for cryptographic operations, which contains several security-relevant bugs. To what degree this affects your application is something you need to carefully investigate. See https://github.com/cosmos/cosmjs/issues/1708 for further pointers. Starting with version 0.34.0 the cryptographic library has been replaced. However, private keys might still be at risk. dependencies: '@cosmjs/encoding': 0.27.1 '@cosmjs/math': 0.27.1 @@ -1689,8 +1690,8 @@ packages: - utf-8-validate dev: false - /@dydxprotocol/v4-localization@1.1.363: - resolution: {integrity: sha512-5/BBBp3lCXLc5fVLmfmIdldV5Of3UIT/GmPTSGHk5L4t0OVmVyiaHhQY+8OnVJMeLxNpZZMtYfMdiZpefQ/JPA==} + /@dydxprotocol/v4-localization@1.1.364: + resolution: {integrity: sha512-O0pmALR1Rpvn0Crj6GRHvZqd+nv947+RAVsay8mGQt3RRIQN0Cers32N0dUszEeBALKmaZUnXoFV4q/UWFE4Cw==} dev: false /@dydxprotocol/v4-proto@7.0.5: diff --git a/public/configs/v1/env.json b/public/configs/v1/env.json index ac8093008a..43adbbeab1 100644 --- a/public/configs/v1/env.json +++ b/public/configs/v1/env.json @@ -61,6 +61,7 @@ "mintscan": "https://mintscan.io/dydx-testnet/txs/{tx_hash}", "blogs": "https://www.dydx.foundation/blog", "foundation": "https://www.dydx.foundation", + "fundingComparison": "https://dydx.dnda.ai/", "help": "https://help.dydx.exchange/", "vaultLearnMore": "https://help.dydx.exchange", "vaultMetrics": null, @@ -110,6 +111,7 @@ "mintscan": "https://mintscan.io/dydx-testnet/txs/{tx_hash}", "blogs": "https://www.dydx.foundation/blog", "foundation": "https://www.dydx.foundation", + "fundingComparison": "https://dydx.dnda.ai/", "help": "https://help.dydx.exchange/", "vaultLearnMore": "https://help.dydx.exchange", "vaultMetrics": null, @@ -164,6 +166,7 @@ "feedback": "https://docs.google.com/forms/d/e/1FAIpQLSezLsWCKvAYDEb7L-2O4wOON1T56xxro9A2Azvl6IxXHP_15Q/viewform", "blogs": "https://www.dydx.foundation/blog", "foundation": "https://www.dydx.foundation", + "fundingComparison": "https://dydx.dnda.ai/", "help": "https://help.dydx.exchange/", "vaultLearnMore": "https://help.dydx.exchange", "vaultMetrics": null, @@ -213,6 +216,7 @@ "feedback": "[HTTP link to feedback form, can be null]", "blogs": "[HTTP link to blogs, can be null]", "foundation": "[HTTP link to foundation, can be null]", + "fundingComparison": "[HTTP link to funding comparison, can be null]", "reduceOnlyLearnMore": "[HTTP link to reduce-only learn more, can be null]", "documentation": "[HTTP link to documentation, can be null]", "community": "[HTTP link to community, can be null]", diff --git a/src/hooks/useURLConfigs.ts b/src/hooks/useURLConfigs.ts index c05f10f0d5..c41f2daedd 100644 --- a/src/hooks/useURLConfigs.ts +++ b/src/hooks/useURLConfigs.ts @@ -14,6 +14,7 @@ export interface LinksConfigs { equityTiersLearnMore?: string; feedback?: string; foundation?: string; + fundingComparison?: string; governanceLearnMore?: string; help?: string; initialMarginFractionLearnMore?: string; @@ -67,6 +68,7 @@ export const useURLConfigs = (): LinksConfigs => { equityTiersLearnMore: linksConfigs.equityTiersLearnMore, feedback: linksConfigs.feedback ?? FALLBACK_URL, foundation: linksConfigs.foundation ?? FALLBACK_URL, + fundingComparison: linksConfigs.fundingComparison ?? FALLBACK_URL, governanceLearnMore: linksConfigs.governanceLearnMore ?? FALLBACK_URL, help: linksConfigs.help ?? FALLBACK_URL, vaultLearnMore: linksConfigs.vaultLearnMore ?? FALLBACK_URL, diff --git a/src/layout/Header/HeaderDesktop.tsx b/src/layout/Header/HeaderDesktop.tsx index f20fbb0082..d7d3fa8080 100644 --- a/src/layout/Header/HeaderDesktop.tsx +++ b/src/layout/Header/HeaderDesktop.tsx @@ -43,7 +43,8 @@ import { testFlags } from '@/lib/testFlags'; export const HeaderDesktop = () => { const stringGetter = useStringGetter(); - const { documentation, community, mintscanBase, exchangeStats } = useURLConfigs(); + const { documentation, community, mintscanBase, exchangeStats, fundingComparison } = + useURLConfigs(); const dispatch = useAppDispatch(); const { chainTokenLabel } = useTokenConfigs(); const { dydxAccounts } = useAccounts(); @@ -126,6 +127,12 @@ export const HeaderDesktop = () => { label: stringGetter({ key: STRING_KEYS.MINTSCAN }), href: mintscanBase, }, + { + value: 'FUNDING_COMPARISON', + slotBefore: , + label: stringGetter({ key: STRING_KEYS.FUNDING_COMPARISON }), + href: fundingComparison, + }, { value: 'COMMUNITY', slotBefore: , From 8b8117985c55f9580ad6d06e882e237454220d3b Mon Sep 17 00:00:00 2001 From: Luka Buzaladze Date: Fri, 5 Dec 2025 13:44:42 -0500 Subject: [PATCH 04/13] feat: use new fee rebate leaderboard (#2006) --- src/hooks/rewards/hooks.ts | 71 +++++++++++++++++++++ src/hooks/rewards/util.ts | 5 ++ src/pages/token/LaunchIncentivesPanel.tsx | 14 ++-- src/pages/token/RewardsLeaderboardPanel.tsx | 53 ++++++++------- 4 files changed, 115 insertions(+), 28 deletions(-) diff --git a/src/hooks/rewards/hooks.ts b/src/hooks/rewards/hooks.ts index fb91461f76..04cb490d6a 100644 --- a/src/hooks/rewards/hooks.ts +++ b/src/hooks/rewards/hooks.ts @@ -11,6 +11,7 @@ import { mapIfPresent } from '@/lib/do'; import { useQueryChaosLabsIncentives } from '../useQueryChaosLabsIncentives'; import { CURRENT_SURGE_REWARDS_DETAILS, + feesToEstimatedDollarRewards, pointsToEstimatedDollarRewards, pointsToEstimatedDydxRewards, } from './util'; @@ -182,3 +183,73 @@ export type ChaosLabsCompetitionItem = { dollarReward: number; pnl: number; }; + +export function useChaosLabsFeeLeaderboard({ + address, + dydxPrice, +}: { + address?: string; + dydxPrice: number | undefined; +}) { + return useQuery({ + queryKey: ['chaoslabs/fee-leaderboard', address], + queryFn: wrapAndLogError( + () => getChaosLabsFeeLeaderboard({ address, dydxPrice }), + 'LaunchIncentives/fetchFeeLeaderboard', + true + ), + }); +} + +export type ChaosLabsFeeLeaderboardItem = { + address: string; + total_fees: number; + rank: number; + estimatedDollarRewards: number; + estimatedDydxRewards: number; +}; + +type ChaosLabsFeeLeaderboardResponse = { + success: boolean; + addressEntry?: ChaosLabsFeeLeaderboardItem; + data: ChaosLabsFeeLeaderboardItem[]; + pagination?: { + total: number; + totalPages: number; + page: number; + perPage: number; + }; +}; + +const addRewardsToLeaderboardEntry = ( + entry: ChaosLabsFeeLeaderboardItem, + dydxPrice: number | undefined +) => { + const dollarRewards = feesToEstimatedDollarRewards(entry.total_fees); + const dydxRewards = dydxPrice ? dollarRewards / dydxPrice : 0; + return { + ...entry, + estimatedDollarRewards: dollarRewards, + estimatedDydxRewards: dydxRewards, + }; +}; + +async function getChaosLabsFeeLeaderboard({ + address, + dydxPrice, +}: { + address?: string; + dydxPrice: number | undefined; +}) { + const res = await fetch( + `https://pp-external-api-ffb2ad95ef03.herokuapp.com/api/dydx-fee-leaderboard?perPage=1000${address ? `&address=${address}` : ''}` + ); + + const data = (await res.json()) as ChaosLabsFeeLeaderboardResponse; + return { + leaderboard: data.data.map((entry) => addRewardsToLeaderboardEntry(entry, dydxPrice)), + addressEntry: data.addressEntry + ? addRewardsToLeaderboardEntry(data.addressEntry, dydxPrice) + : undefined, + }; +} diff --git a/src/hooks/rewards/util.ts b/src/hooks/rewards/util.ts index 1a68e24dfa..d0f9d6a661 100644 --- a/src/hooks/rewards/util.ts +++ b/src/hooks/rewards/util.ts @@ -18,6 +18,11 @@ export function pointsToEstimatedDollarRewards( return (points / totalPoints) * totalUsdRewards; } +export function feesToEstimatedDollarRewards(totalFees?: number): number { + if (!totalFees) return 0; + return totalFees * 0.5; +} + // Move to Chaos Labs query once its available export const CURRENT_SURGE_REWARDS_DETAILS = { season: 9, diff --git a/src/pages/token/LaunchIncentivesPanel.tsx b/src/pages/token/LaunchIncentivesPanel.tsx index 39ab1699b6..e736b462d9 100644 --- a/src/pages/token/LaunchIncentivesPanel.tsx +++ b/src/pages/token/LaunchIncentivesPanel.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; +import { BonsaiCore } from '@/bonsai/ontology'; import { Duration } from 'luxon'; import styled from 'styled-components'; import tw from 'twin.macro'; @@ -11,7 +12,7 @@ import { isDev } from '@/constants/networks'; import { TOKEN_DECIMALS } from '@/constants/numbers'; import { StatsigFlags } from '@/constants/statsig'; -import { useChaosLabsUsdRewards } from '@/hooks/rewards/hooks'; +import { useChaosLabsFeeLeaderboard } from '@/hooks/rewards/hooks'; import { CURRENT_SURGE_REWARDS_DETAILS } from '@/hooks/rewards/util'; import { useAccounts } from '@/hooks/useAccounts'; import { useBreakpoints } from '@/hooks/useBreakpoints'; @@ -32,7 +33,7 @@ import { Panel } from '@/components/Panel'; import { SuccessTag, TagSize } from '@/components/Tag'; import { WithTooltip } from '@/components/WithTooltip'; -import { useAppDispatch } from '@/state/appTypes'; +import { useAppDispatch, useAppSelector } from '@/state/appTypes'; import { markLaunchIncentivesSeen } from '@/state/appUiConfigs'; import { openDialog } from '@/state/dialogs'; @@ -132,10 +133,11 @@ const September2025RewardsPanel = () => { const Sept2025RewardsPanel = () => { const stringGetter = useStringGetter(); const { dydxAddress } = useAccounts(); + const dydxPrice = useAppSelector(BonsaiCore.rewardParams.data).tokenPrice; - const { data: incentiveRewards, isLoading } = useChaosLabsUsdRewards({ - dydxAddress, - totalUsdRewards: CURRENT_SURGE_REWARDS_DETAILS.rewardAmountUsd, + const { data, isLoading } = useChaosLabsFeeLeaderboard({ + address: dydxAddress, + dydxPrice, }); return ( @@ -163,7 +165,7 @@ const Sept2025RewardsPanel = () => { diff --git a/src/pages/token/RewardsLeaderboardPanel.tsx b/src/pages/token/RewardsLeaderboardPanel.tsx index e2f8097803..b30edd533a 100644 --- a/src/pages/token/RewardsLeaderboardPanel.tsx +++ b/src/pages/token/RewardsLeaderboardPanel.tsx @@ -1,10 +1,11 @@ import { useCallback } from 'react'; +import { BonsaiCore } from '@/bonsai/ontology'; import styled from 'styled-components'; import { STRING_KEYS, StringGetterFunction } from '@/constants/localization'; -import { ChaosLabsLeaderboardItem, useChaosLabsPointsDistribution } from '@/hooks/rewards/hooks'; +import { ChaosLabsFeeLeaderboardItem, useChaosLabsFeeLeaderboard } from '@/hooks/rewards/hooks'; import { CURRENT_SURGE_REWARDS_DETAILS } from '@/hooks/rewards/util'; import { useAccounts } from '@/hooks/useAccounts'; import { useStringGetter } from '@/hooks/useStringGetter'; @@ -18,6 +19,8 @@ import { Output, OutputType } from '@/components/Output'; import { Panel } from '@/components/Panel'; import { ColumnDef, Table } from '@/components/Table'; +import { useAppSelector } from '@/state/appTypes'; + import { exportCSV } from '@/lib/csv'; import { truncateAddress } from '@/lib/wallet'; @@ -29,10 +32,14 @@ export enum RewardsLeaderboardTableColumns { export const RewardsLeaderboardPanel = () => { const stringGetter = useStringGetter(); - const { data, isLoading } = useChaosLabsPointsDistribution(); const { dydxAddress } = useAccounts(); + const dydxPrice = useAppSelector(BonsaiCore.rewardParams.data).tokenPrice; + const { data: feeLeaderboard, isLoading } = useChaosLabsFeeLeaderboard({ + address: dydxAddress, + dydxPrice, + }); - const getRowKey = useCallback((row: ChaosLabsLeaderboardItem) => row.rank, []); + const getRowKey = useCallback((row: ChaosLabsFeeLeaderboardItem) => row.rank, []); const columns = Object.values(RewardsLeaderboardTableColumns).map( (key: RewardsLeaderboardTableColumns) => @@ -44,11 +51,11 @@ export const RewardsLeaderboardPanel = () => { ); const onDownload = () => { - if (!data) return; + if (!feeLeaderboard) return; - const csvRows = data.map((item) => ({ + const csvRows = feeLeaderboard?.leaderboard.map((item) => ({ rank: item.rank, - address: item.account, + address: item.address, estimatedRewards: item.estimatedDydxRewards, })); @@ -90,7 +97,7 @@ export const RewardsLeaderboardPanel = () => {
<$Table label={stringGetter({ key: STRING_KEYS.LEADERBOARD })} - data={data ?? []} + data={feeLeaderboard?.leaderboard ?? []} tableId="trading-rewards" getRowKey={getRowKey} columns={columns} @@ -99,7 +106,7 @@ export const RewardsLeaderboardPanel = () => { direction: 'ascending', }} getIsRowPinned={(row) => { - return row.account === dydxAddress; + return row.address === dydxAddress; }} slotEmpty={ isLoading ? ( @@ -111,9 +118,9 @@ export const RewardsLeaderboardPanel = () => {
) } - getRowAttributes={({ account }) => ({ + getRowAttributes={({ address }) => ({ style: { - backgroundColor: account === dydxAddress ? 'var(--color-accent-faded)' : undefined, + backgroundColor: address === dydxAddress ? 'var(--color-accent-faded)' : undefined, }, })} selectionBehavior="replace" @@ -184,7 +191,7 @@ const getRewardsLeaderboardTableColumnDef = ({ key: RewardsLeaderboardTableColumns; stringGetter: StringGetterFunction; dydxAddress?: string; -}): ColumnDef => ({ +}): ColumnDef => ({ ...( { [RewardsLeaderboardTableColumns.Rank]: { @@ -195,7 +202,7 @@ const getRewardsLeaderboardTableColumnDef = ({ {stringGetter({ key: STRING_KEYS.RANK })}
), - renderCell: ({ rank, account }) => ( + renderCell: ({ rank, address }) => (
@@ -205,7 +212,7 @@ const getRewardsLeaderboardTableColumnDef = ({ {rank === 1 && } {rank === 2 && } {rank === 3 && } - {account === dydxAddress && ( + {address === dydxAddress && (
{stringGetter({ key: STRING_KEYS.YOU })} @@ -217,23 +224,23 @@ const getRewardsLeaderboardTableColumnDef = ({ }, [RewardsLeaderboardTableColumns.Trader]: { columnKey: RewardsLeaderboardTableColumns.Trader, - getCellValue: (row) => row.account, + getCellValue: (row) => row.address, label: (
{stringGetter({ key: STRING_KEYS.TRADER })}
), - renderCell: ({ account }) => ( + renderCell: ({ address }) => (
- {truncateAddress(account)} + {truncateAddress(address)} ), - renderCell: ({ estimatedDydxRewards, account }) => ( + renderCell: ({ estimatedDydxRewards, address }) => ( ), }, - } satisfies Record> + } satisfies Record> )[key], }); From 210f3d5954fdef0a3955565b14aa67c3e2707097 Mon Sep 17 00:00:00 2001 From: Luka Buzaladze Date: Mon, 8 Dec 2025 10:28:17 -0500 Subject: [PATCH 05/13] fix: initial state of leaderboard doesn't have price (#2008) --- src/hooks/rewards/hooks.ts | 36 ++++++++------------- src/pages/token/LaunchIncentivesPanel.tsx | 10 ++++-- src/pages/token/RewardsLeaderboardPanel.tsx | 30 ++++++++++++----- 3 files changed, 43 insertions(+), 33 deletions(-) diff --git a/src/hooks/rewards/hooks.ts b/src/hooks/rewards/hooks.ts index 04cb490d6a..eb7c501943 100644 --- a/src/hooks/rewards/hooks.ts +++ b/src/hooks/rewards/hooks.ts @@ -184,24 +184,18 @@ export type ChaosLabsCompetitionItem = { pnl: number; }; -export function useChaosLabsFeeLeaderboard({ - address, - dydxPrice, -}: { - address?: string; - dydxPrice: number | undefined; -}) { +export function useChaosLabsFeeLeaderboard({ address }: { address?: string }) { return useQuery({ queryKey: ['chaoslabs/fee-leaderboard', address], queryFn: wrapAndLogError( - () => getChaosLabsFeeLeaderboard({ address, dydxPrice }), + () => getChaosLabsFeeLeaderboard({ address }), 'LaunchIncentives/fetchFeeLeaderboard', true ), }); } -export type ChaosLabsFeeLeaderboardItem = { +export type ChaosLabsFeeLeaderboardItemWithRewards = { address: string; total_fees: number; rank: number; @@ -209,6 +203,12 @@ export type ChaosLabsFeeLeaderboardItem = { estimatedDydxRewards: number; }; +export type ChaosLabsFeeLeaderboardItem = { + address: string; + total_fees: number; + rank: number; +}; + type ChaosLabsFeeLeaderboardResponse = { success: boolean; addressEntry?: ChaosLabsFeeLeaderboardItem; @@ -221,10 +221,10 @@ type ChaosLabsFeeLeaderboardResponse = { }; }; -const addRewardsToLeaderboardEntry = ( +export const addRewardsToLeaderboardEntry = ( entry: ChaosLabsFeeLeaderboardItem, dydxPrice: number | undefined -) => { +): ChaosLabsFeeLeaderboardItemWithRewards => { const dollarRewards = feesToEstimatedDollarRewards(entry.total_fees); const dydxRewards = dydxPrice ? dollarRewards / dydxPrice : 0; return { @@ -234,22 +234,14 @@ const addRewardsToLeaderboardEntry = ( }; }; -async function getChaosLabsFeeLeaderboard({ - address, - dydxPrice, -}: { - address?: string; - dydxPrice: number | undefined; -}) { +async function getChaosLabsFeeLeaderboard({ address }: { address?: string }) { const res = await fetch( `https://pp-external-api-ffb2ad95ef03.herokuapp.com/api/dydx-fee-leaderboard?perPage=1000${address ? `&address=${address}` : ''}` ); const data = (await res.json()) as ChaosLabsFeeLeaderboardResponse; return { - leaderboard: data.data.map((entry) => addRewardsToLeaderboardEntry(entry, dydxPrice)), - addressEntry: data.addressEntry - ? addRewardsToLeaderboardEntry(data.addressEntry, dydxPrice) - : undefined, + leaderboard: data.data, + addressEntry: data.addressEntry, }; } diff --git a/src/pages/token/LaunchIncentivesPanel.tsx b/src/pages/token/LaunchIncentivesPanel.tsx index e736b462d9..0334871bbc 100644 --- a/src/pages/token/LaunchIncentivesPanel.tsx +++ b/src/pages/token/LaunchIncentivesPanel.tsx @@ -12,7 +12,7 @@ import { isDev } from '@/constants/networks'; import { TOKEN_DECIMALS } from '@/constants/numbers'; import { StatsigFlags } from '@/constants/statsig'; -import { useChaosLabsFeeLeaderboard } from '@/hooks/rewards/hooks'; +import { addRewardsToLeaderboardEntry, useChaosLabsFeeLeaderboard } from '@/hooks/rewards/hooks'; import { CURRENT_SURGE_REWARDS_DETAILS } from '@/hooks/rewards/util'; import { useAccounts } from '@/hooks/useAccounts'; import { useBreakpoints } from '@/hooks/useBreakpoints'; @@ -137,8 +137,12 @@ const Sept2025RewardsPanel = () => { const { data, isLoading } = useChaosLabsFeeLeaderboard({ address: dydxAddress, - dydxPrice, }); + const addressEntry = useMemo( + () => + data?.addressEntry ? addRewardsToLeaderboardEntry(data.addressEntry, dydxPrice) : undefined, + [data?.addressEntry, dydxPrice] + ); return (
@@ -165,7 +169,7 @@ const Sept2025RewardsPanel = () => { diff --git a/src/pages/token/RewardsLeaderboardPanel.tsx b/src/pages/token/RewardsLeaderboardPanel.tsx index b30edd533a..76961d1684 100644 --- a/src/pages/token/RewardsLeaderboardPanel.tsx +++ b/src/pages/token/RewardsLeaderboardPanel.tsx @@ -1,11 +1,16 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { BonsaiCore } from '@/bonsai/ontology'; import styled from 'styled-components'; import { STRING_KEYS, StringGetterFunction } from '@/constants/localization'; -import { ChaosLabsFeeLeaderboardItem, useChaosLabsFeeLeaderboard } from '@/hooks/rewards/hooks'; +import { + addRewardsToLeaderboardEntry, + ChaosLabsFeeLeaderboardItem, + ChaosLabsFeeLeaderboardItemWithRewards, + useChaosLabsFeeLeaderboard, +} from '@/hooks/rewards/hooks'; import { CURRENT_SURGE_REWARDS_DETAILS } from '@/hooks/rewards/util'; import { useAccounts } from '@/hooks/useAccounts'; import { useStringGetter } from '@/hooks/useStringGetter'; @@ -34,10 +39,16 @@ export const RewardsLeaderboardPanel = () => { const stringGetter = useStringGetter(); const { dydxAddress } = useAccounts(); const dydxPrice = useAppSelector(BonsaiCore.rewardParams.data).tokenPrice; - const { data: feeLeaderboard, isLoading } = useChaosLabsFeeLeaderboard({ + const { data: feeLeaderboardWithoutRewards, isLoading } = useChaosLabsFeeLeaderboard({ address: dydxAddress, - dydxPrice, }); + const feeLeaderboard = useMemo( + () => + feeLeaderboardWithoutRewards?.leaderboard.map((entry) => + addRewardsToLeaderboardEntry(entry, dydxPrice) + ), + [feeLeaderboardWithoutRewards?.leaderboard, dydxPrice] + ); const getRowKey = useCallback((row: ChaosLabsFeeLeaderboardItem) => row.rank, []); @@ -53,7 +64,7 @@ export const RewardsLeaderboardPanel = () => { const onDownload = () => { if (!feeLeaderboard) return; - const csvRows = feeLeaderboard?.leaderboard.map((item) => ({ + const csvRows = feeLeaderboard?.map((item) => ({ rank: item.rank, address: item.address, estimatedRewards: item.estimatedDydxRewards, @@ -97,7 +108,7 @@ export const RewardsLeaderboardPanel = () => {
<$Table label={stringGetter({ key: STRING_KEYS.LEADERBOARD })} - data={feeLeaderboard?.leaderboard ?? []} + data={feeLeaderboard ?? []} tableId="trading-rewards" getRowKey={getRowKey} columns={columns} @@ -191,7 +202,7 @@ const getRewardsLeaderboardTableColumnDef = ({ key: RewardsLeaderboardTableColumns; stringGetter: StringGetterFunction; dydxAddress?: string; -}): ColumnDef => ({ +}): ColumnDef => ({ ...( { [RewardsLeaderboardTableColumns.Rank]: { @@ -268,6 +279,9 @@ const getRewardsLeaderboardTableColumnDef = ({ /> ), }, - } satisfies Record> + } satisfies Record< + RewardsLeaderboardTableColumns, + ColumnDef + > )[key], }); From a108b69bc62b094bcb202d57e26aee82ab1fb2a6 Mon Sep 17 00:00:00 2001 From: Kefan Cao <76069770+Kefancao@users.noreply.github.com> Date: Mon, 8 Dec 2025 12:38:28 -0500 Subject: [PATCH 06/13] fix: modify swap venues and add options (#2003) Co-authored-by: Sam Newby --- src/hooks/swap/useSwapQuote.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/hooks/swap/useSwapQuote.ts b/src/hooks/swap/useSwapQuote.ts index 22143c5315..721ddda6f0 100644 --- a/src/hooks/swap/useSwapQuote.ts +++ b/src/hooks/swap/useSwapQuote.ts @@ -38,8 +38,13 @@ async function getSkipSwapRoute( sourceAssetChainId: chainId, destAssetDenom: outputTokenDenom, destAssetChainId: chainId, - smartRelay: true, swapVenues: SWAP_VENUES, + smartSwapOptions: { + splitRoutes: true, + }, + smartRelay: true, + allowUnsafe: false, + goFast: true, ...(mode === 'exact-in' ? { amountIn: parseUnits(amount, inputTokenDecimals).toString() } : { amountOut: parseUnits(amount, outputTokenDecimals).toString() }), From 32e685dee08f52881c10c71cf65125c860af0b0c Mon Sep 17 00:00:00 2001 From: Paata Kardava Date: Tue, 9 Dec 2025 20:54:35 +0400 Subject: [PATCH 07/13] feat: spot (#1933) Co-authored-by: Kefan Cao <76069770+Kefancao@users.noreply.github.com> Co-authored-by: Kefan Cao --- package.json | 4 + pnpm-lock.yaml | 49 ++- public/configs/v1/env.json | 80 ++-- src/App.tsx | 14 +- src/bonsai/forms/spot.ts | 299 +++++++++++++ src/bonsai/lib/validationErrors.ts | 35 +- src/bonsai/ontology.ts | 70 +++ src/bonsai/rest/spot.ts | 201 +++++++++ src/bonsai/selectors/base.ts | 19 + src/bonsai/selectors/spot.ts | 74 ++++ src/bonsai/storeLifecycles.ts | 16 + src/bonsai/websocket/spot.ts | 41 ++ src/clients/spotApi.ts | 397 ++++++++++++++++++ src/components/AssetIcon.tsx | 17 +- src/components/CopyButton.tsx | 15 +- src/components/Icon.tsx | 36 ++ src/components/InfoGrid.tsx | 78 ++++ src/components/Loading/Loading.stories.tsx | 11 + src/components/Loading/LoadingAssetIcon.tsx | 27 ++ src/components/Output.tsx | 25 +- src/components/TabGroup.tsx | 37 +- src/components/ValidationAlert.tsx | 9 +- src/constants/candles.ts | 22 +- src/constants/spot.ts | 8 + src/constants/statsig.ts | 2 +- src/constants/styles/colors.ts | 2 + src/constants/tokens.ts | 2 + .../useSpotChartMarketAndResolution.ts | 70 +++ src/hooks/tradingView/useSpotTradingView.ts | 26 +- src/hooks/useAccounts.tsx | 56 ++- src/hooks/useComplianceState.tsx | 12 +- src/hooks/useCurrentSpotToken.ts | 20 + src/hooks/useCustomNotification.ts | 2 +- src/hooks/useEnableSpot.ts | 12 + src/hooks/useEndpointsConfig.ts | 4 +- src/hooks/useNotificationTypes.tsx | 15 +- src/hooks/useSolanaConnection.ts | 21 +- src/hooks/useSpotForm.tsx | 165 ++++++++ src/hooks/useSpotTokenSearch.ts | 35 ++ src/hooks/useSpotTransactionSubmit.ts | 63 +++ src/icons/chef-hat.svg | 5 + src/icons/dollar-sign.svg | 3 + src/icons/ghost.svg | 8 + src/icons/index.ts | 12 + src/icons/percent-sign.svg | 3 + src/icons/plus-circle.svg | 3 + src/icons/scope.svg | 15 + src/icons/sol.svg | 19 + src/icons/solana-simple.svg | 4 + src/icons/user-2.svg | 15 + src/icons/user-group.svg | 5 + src/icons/user-shield.svg | 8 + src/icons/wallet-3.svg | 3 + src/lib/hdKeyManager.ts | 19 +- src/lib/solanaConnection.ts | 17 + src/lib/solanaWallet.ts | 26 ++ src/lib/streaming/BaseSocketIOManager.ts | 151 +++++++ src/lib/streaming/spotCandleStreaming.ts | 120 ++++++ src/lib/streaming/walletPositionsStreaming.ts | 102 +++++ .../tradingView/spotDatafeed/candleService.ts | 65 --- src/lib/tradingView/spotDatafeed/index.ts | 65 ++- src/lib/tradingView/spotDatafeed/streaming.ts | 256 ----------- src/lib/tradingView/spotDatafeed/types.ts | 51 --- src/lib/tradingView/spotDatafeed/utils.ts | 60 +-- src/pages/spot/QuickButtons.tsx | 204 +++++++++ src/pages/spot/Spot.tsx | 317 +++++++++++++- src/pages/spot/SpotFormInput.tsx | 208 +++++++++ src/pages/spot/SpotHeader.tsx | 55 +++ src/pages/spot/SpotHoldingsTable.tsx | 234 +++++++++++ src/pages/spot/SpotHorizontalPanel.tsx | 103 +++++ src/pages/spot/SpotMarketStatsRow.tsx | 109 +++++ src/pages/spot/SpotMarketsDropdown.tsx | 290 +++++++++++++ src/pages/spot/SpotTabs.tsx | 151 +++++++ src/pages/spot/SpotTokenInfo.tsx | 110 +++++ src/pages/spot/SpotTradeForm.tsx | 183 ++++++++ src/pages/spot/SpotTradesTable.tsx | 200 +++++++++ src/pages/spot/StackedIcon.tsx | 71 ++++ src/pages/spot/types.ts | 23 + src/state/_store.ts | 4 + src/state/accountInfoSelectors.ts | 3 + src/state/app.ts | 13 +- src/state/appSelectors.ts | 4 + src/state/appUiConfigs.ts | 61 +++ src/state/appUiConfigsSelectors.ts | 9 + src/state/raw.ts | 55 +++ src/state/spot.ts | 25 ++ src/state/spotForm.ts | 13 + src/state/spotFormSelectors.ts | 87 ++++ src/state/transfers.ts | 61 ++- src/state/transfersSelectors.ts | 19 +- src/state/wallet.ts | 5 +- src/styles/constants.css | 1 + src/styles/globalStyle.ts | 4 + src/styles/themes.ts | 6 + .../Alerts/SkipTransferNotificationRow.tsx | 11 +- src/views/charts/TradingView/SpotTvChart.tsx | 12 +- .../TransferDialogs/TransferStatusDialog.tsx | 6 +- .../WithdrawDialog2/AddressInput.tsx | 6 +- .../WithdrawDialog2/SpotAmountInput.tsx | 79 ++++ .../WithdrawDialog2/SpotWithdrawForm.tsx | 220 ++++++++++ .../WithdrawDialog2/WithdrawDialog2.tsx | 123 ++++-- .../WithdrawDialog2/WithdrawForm.tsx | 2 +- .../WithdrawDialog2/WithdrawStatus.tsx | 78 +++- .../WithdrawDialog2/withdrawSpotHooks.ts | 103 +++++ src/views/menus/AccountMenu/AccountMenu.tsx | 175 +++----- src/views/menus/AccountMenu/SpotActions.tsx | 74 ++++ .../tables/MarketsTable/FavoriteButton.tsx | 31 +- tailwind.config.js | 3 + 108 files changed, 5903 insertions(+), 774 deletions(-) create mode 100644 src/bonsai/forms/spot.ts create mode 100644 src/bonsai/rest/spot.ts create mode 100644 src/bonsai/selectors/spot.ts create mode 100644 src/bonsai/websocket/spot.ts create mode 100644 src/clients/spotApi.ts create mode 100644 src/components/InfoGrid.tsx create mode 100644 src/components/Loading/LoadingAssetIcon.tsx create mode 100644 src/constants/spot.ts create mode 100644 src/hooks/tradingView/useSpotChartMarketAndResolution.ts create mode 100644 src/hooks/useCurrentSpotToken.ts create mode 100644 src/hooks/useEnableSpot.ts create mode 100644 src/hooks/useSpotForm.tsx create mode 100644 src/hooks/useSpotTokenSearch.ts create mode 100644 src/hooks/useSpotTransactionSubmit.ts create mode 100644 src/icons/chef-hat.svg create mode 100644 src/icons/dollar-sign.svg create mode 100644 src/icons/ghost.svg create mode 100644 src/icons/percent-sign.svg create mode 100644 src/icons/plus-circle.svg create mode 100644 src/icons/scope.svg create mode 100644 src/icons/sol.svg create mode 100644 src/icons/solana-simple.svg create mode 100644 src/icons/user-2.svg create mode 100644 src/icons/user-group.svg create mode 100644 src/icons/user-shield.svg create mode 100644 src/icons/wallet-3.svg create mode 100644 src/lib/solanaConnection.ts create mode 100644 src/lib/solanaWallet.ts create mode 100644 src/lib/streaming/BaseSocketIOManager.ts create mode 100644 src/lib/streaming/spotCandleStreaming.ts create mode 100644 src/lib/streaming/walletPositionsStreaming.ts delete mode 100644 src/lib/tradingView/spotDatafeed/candleService.ts delete mode 100644 src/lib/tradingView/spotDatafeed/streaming.ts delete mode 100644 src/lib/tradingView/spotDatafeed/types.ts create mode 100644 src/pages/spot/QuickButtons.tsx create mode 100644 src/pages/spot/SpotFormInput.tsx create mode 100644 src/pages/spot/SpotHeader.tsx create mode 100644 src/pages/spot/SpotHoldingsTable.tsx create mode 100644 src/pages/spot/SpotHorizontalPanel.tsx create mode 100644 src/pages/spot/SpotMarketStatsRow.tsx create mode 100644 src/pages/spot/SpotMarketsDropdown.tsx create mode 100644 src/pages/spot/SpotTabs.tsx create mode 100644 src/pages/spot/SpotTokenInfo.tsx create mode 100644 src/pages/spot/SpotTradeForm.tsx create mode 100644 src/pages/spot/SpotTradesTable.tsx create mode 100644 src/pages/spot/StackedIcon.tsx create mode 100644 src/pages/spot/types.ts create mode 100644 src/state/spot.ts create mode 100644 src/state/spotForm.ts create mode 100644 src/state/spotFormSelectors.ts create mode 100644 src/views/dialogs/TransferDialogs/WithdrawDialog2/SpotAmountInput.tsx create mode 100644 src/views/dialogs/TransferDialogs/WithdrawDialog2/SpotWithdrawForm.tsx create mode 100644 src/views/dialogs/TransferDialogs/WithdrawDialog2/withdrawSpotHooks.ts create mode 100644 src/views/menus/AccountMenu/SpotActions.tsx diff --git a/package.json b/package.json index 73b5d83606..0bbe8ac8d7 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "@react-types/shared": "^3.32.1", "@react-types/table": "^3.13.4", "@reduxjs/toolkit": "^2.2.5", + "@scure/bip39": "^2.0.1", "@skip-go/client": "1.5.8", "@solana/web3.js": "^1.93.0", "@statsig/js-client": "1.4.0", @@ -116,9 +117,11 @@ "@visx/xychart": "^3.1.2", "@wagmi/core": "^2.16.3", "bignumber.js": "^9.1.1", + "bs58": "^6.0.0", "cmdk": "^0.2.0", "crypto-js": "^4.1.1", "cuer": "^0.0.2", + "ed25519-hd-key": "^1.3.0", "export-to-csv": "^1.2.3", "fast-json-stable-stringify": "^2.1.0", "graz": "^0.1.19", @@ -156,6 +159,7 @@ "@ryoppippi/unplugin-typia": "npm:@jsr/ryoppippi__unplugin-typia@^1.1.0", "@testing-library/webdriverio": "^3.2.1", "@trivago/prettier-plugin-sort-imports": "^4.3.0", + "@types/bs58": "^5.0.0", "@types/color": "^3.0.3", "@types/crypto-js": "^4.1.1", "@types/luxon": "^3.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecc09710d2..265fc036fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,9 @@ dependencies: '@reduxjs/toolkit': specifier: ^2.2.5 version: 2.9.0(react-redux@9.2.0)(react@18.3.1) + '@scure/bip39': + specifier: ^2.0.1 + version: 2.0.1 '@skip-go/client': specifier: 1.5.8 version: 1.5.8(@solana/web3.js@1.98.4)(viem@2.38.0) @@ -215,6 +218,9 @@ dependencies: bignumber.js: specifier: ^9.1.1 version: 9.3.1 + bs58: + specifier: ^6.0.0 + version: 6.0.0 cmdk: specifier: ^0.2.0 version: 0.2.1(@types/react@18.3.26)(react-dom@18.3.1)(react@18.3.1) @@ -224,6 +230,9 @@ dependencies: cuer: specifier: ^0.0.2 version: 0.0.2(react-dom@18.3.1)(react@18.3.1)(typescript@5.9.3) + ed25519-hd-key: + specifier: ^1.3.0 + version: 1.3.0 export-to-csv: specifier: ^1.2.3 version: 1.4.0 @@ -331,6 +340,9 @@ devDependencies: '@trivago/prettier-plugin-sort-imports': specifier: ^4.3.0 version: 4.3.0(prettier@3.6.2) + '@types/bs58': + specifier: ^5.0.0 + version: 5.0.0 '@types/color': specifier: ^3.0.3 version: 3.0.6 @@ -4506,7 +4518,6 @@ packages: /@noble/hashes@2.0.1: resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} - dev: true /@noble/secp256k1@1.7.1: resolution: {integrity: sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw==} @@ -8894,6 +8905,10 @@ packages: resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} dev: false + /@scure/base@2.0.0: + resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==} + dev: false + /@scure/bip32@1.1.5: resolution: {integrity: sha512-XyNh1rB0SkEqd3tXcXMi+Xe1fvg+kUIcoRIEujP1Jgv7DqW2r9lg3Ah0NkFaCs9sTkQAQA8kw7xiRXzENi9Rtw==} dependencies: @@ -8954,6 +8969,13 @@ packages: '@scure/base': 1.2.6 dev: false + /@scure/bip39@2.0.1: + resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==} + dependencies: + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + dev: false + /@simplewebauthn/browser@9.0.1: resolution: {integrity: sha512-wD2WpbkaEP4170s13/HUxPcAV5y4ZXaKo1TfNklS5zDefPinIgXOpgz1kpEvobAsaLPa2KeH7AKKX/od1mrBJw==} dependencies: @@ -9961,6 +9983,13 @@ packages: '@types/node': 22.18.8 dev: false + /@types/bs58@5.0.0: + resolution: {integrity: sha512-cAw/jKBzo98m6Xz1X5ETqymWfIMbXbu6nK15W4LQYjeHJkVqSmM5PO8Bd9KVHQJ/F4rHcSso9LcjtgCW6TGu2w==} + deprecated: This is a stub types definition. bs58 provides its own type definitions, so you do not need this installed. + dependencies: + bs58: 6.0.0 + dev: true + /@types/color-convert@2.0.4: resolution: {integrity: sha512-Ub1MmDdyZ7mX//g25uBAoH/mWGd9swVbt8BseymnaE18SU4po/PjmCrHxqIIRjBo3hV/vh1KGr0eMxUhp+t+dQ==} dependencies: @@ -13180,7 +13209,6 @@ packages: /ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} - requiresBuild: true /ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} @@ -13658,7 +13686,6 @@ packages: /base-x@5.0.1: resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} - dev: false /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -13954,7 +13981,6 @@ packages: resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} dependencies: base-x: 5.0.1 - dev: false /bs58check@2.1.2: resolution: {integrity: sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA==} @@ -15524,6 +15550,13 @@ packages: '@noble/hashes': 1.8.0 dev: false + /ed25519-hd-key@1.3.0: + resolution: {integrity: sha512-IWwAyiiuJQhgu3L8NaHb68eJxTu2pgCwxIBdgpLJdKpYZM46+AXePSVTr7fkNKaUOfOL4IrjEUaQvyVRIDP7fg==} + dependencies: + create-hmac: 1.1.7 + tweetnacl: 1.0.3 + dev: false + /edge-paths@3.0.5: resolution: {integrity: sha512-sB7vSrDnFa4ezWQk9nZ/n0FdpdUuC6R1EOrlU3DL+bovcNFK28rqu2emmAUjujYEJTWIgQGqgVVWUZXMnc8iWg==} engines: {node: '>=14.0.0'} @@ -16832,7 +16865,7 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.3.4 get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -21337,7 +21370,7 @@ packages: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.3.4 get-uri: 6.0.5 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -22002,7 +22035,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.3.4 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -23521,7 +23554,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.3.4 socks: 2.8.7 transitivePeerDependencies: - supports-color diff --git a/public/configs/v1/env.json b/public/configs/v1/env.json index 43adbbeab1..8cc3317d49 100644 --- a/public/configs/v1/env.json +++ b/public/configs/v1/env.json @@ -452,7 +452,7 @@ "https://validator.v4dev.dydx.exchange" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", @@ -461,7 +461,8 @@ "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", "stakingAPR": "https://apybara-proxy-web-testnet.infrastructure-34d.workers.dev/v0/protocols/dydx", "faucet": "https://faucet.v4dev.dydx.exchange", - "affiliates": "https://dydx.stg.fuul.xyz" + "affiliates": "https://dydx.stg.fuul.xyz", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -496,13 +497,14 @@ "http://dev2-validator-lb-58813722.us-east-2.elb.amazonaws.com" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", "neutronValidator": "https://neutron-testnet-rpc.polkachu.com/", "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", - "geo": "https://api.dydx.exchange/v4/geo" + "geo": "https://api.dydx.exchange/v4/geo", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -537,7 +539,7 @@ "http://validator-dev3-lb-1393802013.us-east-2.elb.amazonaws.com" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", @@ -546,7 +548,8 @@ "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", "stakingAPR": "https://apybara-proxy-web-testnet.infrastructure-34d.workers.dev/v0/protocols/dydx", "faucet": "http://dev3-faucet-lb-public-1644791410.us-east-2.elb.amazonaws.com", - "affiliates": "https://dydx.stg.fuul.xyz" + "affiliates": "https://dydx.stg.fuul.xyz", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -581,7 +584,7 @@ "https://validator.v4dev4.dydx.exchange" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", @@ -590,7 +593,8 @@ "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", "stakingAPR": "https://apybara-proxy-web-testnet.infrastructure-34d.workers.dev/v0/protocols/dydx", "faucet": "https://faucet.v4dev4.dydx.exchange", - "affiliates": "https://dydx.stg.fuul.xyz" + "affiliates": "https://dydx.stg.fuul.xyz", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -625,13 +629,14 @@ "http://18.223.78.50:26657" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", "neutronValidator": "https://neutron-testnet-rpc.polkachu.com/", "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", - "geo": "https://api.dydx.exchange/v4/geo" + "geo": "https://api.dydx.exchange/v4/geo", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -668,14 +673,14 @@ "https://validator.v4staging.dydx.exchange" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", "neutronValidator": "https://neutron-testnet-rpc.polkachu.com/", "geo": "https://api.dydx.exchange/v4/geo", - "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", - "spotCandleService": "https://pp-candle-service-stag-710ee7adad29.herokuapp.com" + "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -712,13 +717,14 @@ "https://validator.v4staging.dydx.exchange" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", "neutronValidator": "https://neutron-testnet-rpc.polkachu.com/", "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", - "geo": "https://api.dydx.exchange/v4/geo" + "geo": "https://api.dydx.exchange/v4/geo", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "apps": { "ios": { @@ -767,13 +773,14 @@ "https://validator-uswest1.v4staging.dydx.exchange" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", "neutronValidator": "https://neutron-testnet-rpc.polkachu.com/", "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", - "geo": "https://api.dydx.exchange/v4/geo" + "geo": "https://api.dydx.exchange/v4/geo", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -809,7 +816,7 @@ "https://test-dydx-rpc.kingnodes.com" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", @@ -818,8 +825,8 @@ "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", "stakingAPR": "https://apybara-proxy-web-testnet.infrastructure-34d.workers.dev/v0/protocols/dydx", "faucet": "https://faucet.v4testnet.dydx.exchange", - "affiliates": "https://dydx.stg.fuul.xyz", - "spotCandleService": "https://pp-candle-service-stag-710ee7adad29.herokuapp.com" + "affiliates": "https://dydx.stg.fuul.xyz", + "spotApi": "https://dydx-solana-api-staging-e2fb353831a4.herokuapp.com" }, "stakingValidators": [ "dydxvaloper1vvc9vl6z9pu0vt2y79d0ln8zp6qmpmrhxx99h4", @@ -857,7 +864,7 @@ "https://validator.v4testnet.dydx.exchange" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", @@ -866,7 +873,8 @@ "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", "stakingAPR": "https://apybara-proxy-web-testnet.infrastructure-34d.workers.dev/v0/protocols/dydx", "faucet": "https://faucet.v4testnet.dydx.exchange", - "affiliates": "https://dydx.stg.fuul.xyz" + "affiliates": "https://dydx.stg.fuul.xyz", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -901,7 +909,7 @@ "https://dydx-testnet.nodefleet.org" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", @@ -910,7 +918,8 @@ "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", "stakingAPR": "https://apybara-proxy-web-testnet.infrastructure-34d.workers.dev/v0/protocols/dydx", "faucet": "https://faucet.v4testnet.dydx.exchange", - "affiliates": "https://dydx.stg.fuul.xyz" + "affiliates": "https://dydx.stg.fuul.xyz", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -945,7 +954,7 @@ "https://test-dydx.kingnodes.com" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", @@ -954,7 +963,8 @@ "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", "stakingAPR": "https://apybara-proxy-web-testnet.infrastructure-34d.workers.dev/v0/protocols/dydx", "faucet": "https://faucet.v4testnet.dydx.exchange", - "affiliates": "https://dydx.stg.fuul.xyz" + "affiliates": "https://dydx.stg.fuul.xyz", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -989,7 +999,7 @@ "https://dydx-rpc.liquify.com/api=8878132/dydx" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", @@ -998,7 +1008,8 @@ "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", "stakingAPR": "https://apybara-proxy-web-testnet.infrastructure-34d.workers.dev/v0/protocols/dydx", "faucet": "https://faucet.v4testnet.dydx.exchange", - "affiliates": "https://dydx.stg.fuul.xyz" + "affiliates": "https://dydx.stg.fuul.xyz", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -1033,7 +1044,7 @@ "https://dydx-testnet-rpc.polkachu.com/" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", @@ -1042,7 +1053,8 @@ "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", "stakingAPR": "https://apybara-proxy-web-testnet.infrastructure-34d.workers.dev/v0/protocols/dydx", "faucet": "https://faucet.v4testnet.dydx.exchange", - "affiliates": "https://dydx.stg.fuul.xyz" + "affiliates": "https://dydx.stg.fuul.xyz", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -1077,7 +1089,7 @@ "https://dydx-testnet-full-rpc.public.blastapi.io/" ], "skip": "https://api.skip.build", - "solanaRpcUrl": "https://api.mainnet-beta.solana.com/", + "solanaRpcUrl": "https://mainnet.helius-rpc.com/?api-key=b446d8fe-aa37-4457-bf61-c742aa0a1c95", "nobleValidator": "https://noble-testnet-rpc.polkachu.com/", "osmosisValidator": "https://rpc.osmotest5.osmosis.zone/", "metadataService": "https://66iv2m87ol.execute-api.ap-northeast-1.amazonaws.com/mainnet/metadata-service/v1", @@ -1086,7 +1098,8 @@ "geoV2": "https://geo-whitelist-web-mainnet-preview.infrastructure-34d.workers.dev/", "stakingAPR": "https://apybara-proxy-web-testnet.infrastructure-34d.workers.dev/v0/protocols/dydx", "faucet": "https://faucet.v4testnet.dydx.exchange", - "affiliates": "https://dydx.stg.fuul.xyz" + "affiliates": "https://dydx.stg.fuul.xyz", + "spotApi": "https://dydx-solana-api-prod-89bf4c933ba0.herokuapp.com" }, "stakingValidators": [], "featureFlags": { @@ -1130,7 +1143,8 @@ "geo": "[geo endpoint for mainnet]", "geoV2": "[geo v2 endpoint for mainnet]", "stakingAPR": "[staking APR endpoint for mainnet]", - "affiliates": "[affiliates endpoint for mainnet]" + "affiliates": "[affiliates endpoint for mainnet]", + "spotApi": "[spot api endpoint for mainnet]" }, "stakingValidators": [], "featureFlags": { diff --git a/src/App.tsx b/src/App.tsx index c0064be8ad..5e88788717 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,7 +37,6 @@ import { FooterMobile } from '@/layout/Footer/FooterMobile'; import { HeaderDesktop } from '@/layout/Header/HeaderDesktop'; import { NotificationsToastArea } from '@/layout/NotificationsToastArea'; -import { testFlags } from '@/lib/testFlags'; import { parseLocationHash } from '@/lib/urlUtils'; import { config, privyConfig } from '@/lib/wagmi'; @@ -53,6 +52,7 @@ import { useAnalytics } from './hooks/useAnalytics'; import { useBreakpoints } from './hooks/useBreakpoints'; import { useCommandMenu } from './hooks/useCommandMenu'; import { useComplianceState } from './hooks/useComplianceState'; +import { useEnableSpot } from './hooks/useEnableSpot'; import { useInitializePage } from './hooks/useInitializePage'; import { useLocalStorage } from './hooks/useLocalStorage'; import { useReferralCode } from './hooks/useReferralCode'; @@ -68,6 +68,7 @@ import { TurnkeyAuthProvider } from './providers/TurnkeyAuthProvider'; import { TurnkeyWalletProvider } from './providers/TurnkeyWalletProvider'; import { persistor } from './state/_store'; import { setOnboardedThisSession } from './state/account'; +import { setCurrentPath } from './state/app'; import { appQueryClient } from './state/appQueryClient'; import { useAppDispatch, useAppSelector } from './state/appTypes'; import { AppTheme, setAppThemeSetting } from './state/appUiConfigs'; @@ -106,12 +107,19 @@ const Content = () => { const { chainTokenLabel } = useTokenConfigs(); const location = useLocation(); + const dispatch = useAppDispatch(); const isShowingHeader = isNotTablet; const isShowingFooter = useShouldShowFooter(); const abDefaultToMarkets = useCustomFlagValue(CustomFlags.abDefaultToMarkets); const isSimpleUi = useSimpleUiEnabled(); const { showComplianceBanner } = useComplianceState(); const isSimpleUiUserMenuOpen = useAppSelector(getIsUserMenuOpen); + const isSpotEnabled = useEnableSpot(); + + // Track current path in Redux for conditional polling + useEffect(() => { + dispatch(setCurrentPath(location.pathname)); + }, [location.pathname, dispatch]); const pathFromHash = useMemo(() => { if (location.hash === '') { @@ -201,7 +209,9 @@ const Content = () => { } /> - {testFlags.spot && } />} + {isSpotEnabled && ( + } /> + )} } /> diff --git a/src/bonsai/forms/spot.ts b/src/bonsai/forms/spot.ts new file mode 100644 index 0000000000..caac4cbdab --- /dev/null +++ b/src/bonsai/forms/spot.ts @@ -0,0 +1,299 @@ +import { LAMPORTS_PER_SOL } from '@solana/web3.js'; + +import { MIN_SOL_RESERVE } from '@/constants/spot'; + +import { SpotApiCreateTransactionRequest, SpotApiSide, SpotApiTradeRoute } from '@/clients/spotApi'; +import { calc, mapIfPresent } from '@/lib/do'; +import { AttemptNumber, MustNumber } from '@/lib/numbers'; + +import { createForm, createVanillaReducer } from '../lib/forms'; +import { ErrorType, simpleValidationError, ValidationError } from '../lib/validationErrors'; + +export enum SpotSide { + BUY = 'BUY', + SELL = 'SELL', +} + +export enum SpotBuyInputType { + USD = 'USD', + SOL = 'SOL', +} + +export enum SpotSellInputType { + PERCENT = 'PERCENT', + USD = 'USD', +} + +export interface SpotFormState { + side: SpotSide; + buyInputType: SpotBuyInputType; + sellInputType: SpotSellInputType; + size: string; +} + +const initialState: SpotFormState = { + side: SpotSide.BUY, + buyInputType: SpotBuyInputType.USD, + sellInputType: SpotSellInputType.PERCENT, + size: '', +}; + +const reducer = createVanillaReducer({ + initialState, + actions: { + setSide: (state, side: SpotSide) => ({ + ...state, + side, + size: '', // Clear size when switching side + }), + setBuyInputType: (state, buyInputType: SpotBuyInputType) => ({ + ...state, + buyInputType, + }), + setSellInputType: (state, sellInputType: SpotSellInputType) => ({ + ...state, + sellInputType, + }), + setSize: (state, size: string) => ({ + ...state, + size, + }), + reset: () => initialState, + }, +}); + +export interface SpotFormInputData { + tokenPriceUsd: number | undefined; + solPriceUsd: number | undefined; + userSolBalance: number | undefined; + userTokenBalance: number | undefined; + tokenMint: string | undefined; + decimals: number | undefined; + pairAddress: string | undefined; + tradeRoute: SpotApiTradeRoute | undefined; + solanaAddress: string | undefined; + isReady: boolean; + isAsyncDataReady: boolean; + isRestReady: boolean; +} + +export interface SpotAmounts { + sol: number; + token: number; + usd: number; + percent: number | undefined; +} + +export interface SpotSummaryData { + amounts: SpotAmounts | undefined; + payload: SpotApiCreateTransactionRequest | undefined; +} + +function calculateSummary(state: SpotFormState, inputData: SpotFormInputData): SpotSummaryData { + const parsedSize = AttemptNumber(state.size); + + const amounts: SpotAmounts | undefined = calc(() => + mapIfPresent( + parsedSize, + inputData.tokenPriceUsd, + inputData.solPriceUsd, + (size, tokenUsdPrice, solUsdPrice): SpotAmounts => { + if (state.side === SpotSide.BUY) { + if (state.buyInputType === SpotBuyInputType.SOL) { + const sol = size; + const usd = sol * solUsdPrice; + const token = usd / tokenUsdPrice; + return { sol, usd, token, percent: undefined }; + } + + const usd = size; + const sol = usd / solUsdPrice; + const token = usd / tokenUsdPrice; + return { sol, usd, token, percent: undefined }; + } + + if (state.sellInputType === SpotSellInputType.USD) { + const usd = size; + const sol = usd / solUsdPrice; + const token = usd / tokenUsdPrice; + const percent = + inputData.userTokenBalance != null && inputData.userTokenBalance > 0 + ? (token / inputData.userTokenBalance) * 100 + : undefined; + return { sol, usd, token, percent }; + } + + const percent = size; + const token = (percent / 100) * (inputData.userTokenBalance ?? 0); + const usd = token * tokenUsdPrice; + const sol = usd / solUsdPrice; + return { sol, usd, token, percent }; + } + ) + ); + + const payload = calc((): SpotApiCreateTransactionRequest | undefined => { + if (state.side === SpotSide.BUY) { + return mapIfPresent( + amounts?.sol, + inputData.tokenMint, + inputData.solanaAddress, + inputData.pairAddress, + inputData.tradeRoute, + (solAmount, tokenMint, solanaAddress, pairAddress, tradeRoute) => { + const inAmount = Math.floor(solAmount * LAMPORTS_PER_SOL).toString(); + return { + account: solanaAddress, + tokenMint, + side: SpotApiSide.BUY, + inAmount, + pool: pairAddress, + tradeRoute, + }; + } + ); + } + + return mapIfPresent( + amounts?.token, + inputData.tokenMint, + inputData.solanaAddress, + inputData.pairAddress, + inputData.tradeRoute, + inputData.decimals, + (tokenAmount, tokenMint, solanaAddress, pairAddress, tradeRoute, decimals) => { + const inAmount = Math.floor(tokenAmount * decimals).toString(); + return { + account: solanaAddress, + tokenMint, + side: SpotApiSide.SELL, + inAmount, + pool: pairAddress, + tradeRoute, + }; + } + ); + }); + + return { amounts, payload }; +} + +export function getErrors( + state: SpotFormState, + inputData: SpotFormInputData, + summary: SpotSummaryData +): ValidationError[] { + const validationErrors: ValidationError[] = []; + + const parsedSize = AttemptNumber(state.size); + + if (parsedSize == null || parsedSize <= 0) { + validationErrors.push( + simpleValidationError({ + code: 'SPOT_AMOUNT_EMPTY', + type: ErrorType.error, + fields: ['size'], + titleFallback: 'Enter Amount', + }) + ); + return validationErrors; + } + + if ( + inputData.userSolBalance == null || + inputData.tokenPriceUsd == null || + inputData.solPriceUsd == null || + inputData.tokenMint == null || + inputData.pairAddress == null || + inputData.tradeRoute == null || + inputData.solanaAddress == null || + inputData.decimals == null || + !inputData.isAsyncDataReady + ) { + validationErrors.push( + simpleValidationError({ + code: 'SPOT_MISSING_DATA', + type: ErrorType.error, + titleFallback: 'Missing Data', + textFallback: 'Market data is loading. Please wait...', + }) + ); + return validationErrors; + } + + if (state.side === SpotSide.BUY) { + const requiredSol = MustNumber(summary.amounts?.sol); + const availableSol = inputData.userSolBalance; + + if (requiredSol > availableSol) { + validationErrors.push( + simpleValidationError({ + code: 'SPOT_INSUFFICIENT_SOL', + type: ErrorType.error, + fields: ['size'], + titleFallback: 'Insufficient Balance', + textFallback: 'Insufficient SOL balance for this purchase', + }) + ); + } + + if (availableSol - requiredSol < MIN_SOL_RESERVE) { + validationErrors.push( + simpleValidationError({ + code: 'SPOT_LOW_SOL_FOR_FEES', + type: ErrorType.warning, + titleFallback: 'Low SOL Balance', + textFallback: + 'After this trade, your remaining SOL may be insufficient for future transaction fees', + }) + ); + } + } else { + const requiredToken = MustNumber(summary.amounts?.token); + const availableToken = MustNumber(inputData.userTokenBalance); + const sellPercentage = MustNumber(summary.amounts?.percent); + + if (requiredToken > availableToken) { + validationErrors.push( + simpleValidationError({ + code: 'SPOT_INSUFFICIENT_TOKEN', + type: ErrorType.error, + fields: ['size'], + titleFallback: 'Insufficient Balance', + textFallback: 'Insufficient token balance for this trade', + }) + ); + } + + if (state.sellInputType === SpotSellInputType.PERCENT && sellPercentage > 100) { + validationErrors.push( + simpleValidationError({ + code: 'SPOT_PERCENT_TOO_HIGH', + type: ErrorType.error, + fields: ['size'], + titleFallback: 'Invalid Percent', + textFallback: 'Percent must be 100 or less', + }) + ); + } + + if (inputData.userSolBalance < MIN_SOL_RESERVE) { + validationErrors.push( + simpleValidationError({ + code: 'SPOT_LOW_SOL_FOR_FEES', + type: ErrorType.warning, + titleFallback: 'Low SOL Balance', + textFallback: 'Your SOL balance may be insufficient for transaction fees', + }) + ); + } + } + + return validationErrors; +} + +export const SpotFormFns = createForm({ + reducer, + calculateSummary, + getErrors, +}); diff --git a/src/bonsai/lib/validationErrors.ts b/src/bonsai/lib/validationErrors.ts index f2c5e288ce..514e419c6a 100644 --- a/src/bonsai/lib/validationErrors.ts +++ b/src/bonsai/lib/validationErrors.ts @@ -20,8 +20,9 @@ export interface ErrorResources { } export interface ErrorString { - stringKey: string; + stringKey?: string; params?: { [key: string]: ErrorParam }; + fallback?: string; } export interface ErrorParam { @@ -74,6 +75,8 @@ interface SimpleValidationErrorParams { fields?: string[]; titleKey?: string; textKey?: string; + titleFallback?: string; + textFallback?: string; titleParams?: { [key: string]: ErrorParam }; textParams?: { [key: string]: ErrorParam }; learnMoreUrlKey?: keyof (typeof LINKS_CONFIG_MAP)[keyof typeof LINKS_CONFIG_MAP]; @@ -85,6 +88,8 @@ export function simpleValidationError({ fields, titleKey, textKey, + titleFallback, + textFallback, textParams, titleParams, learnMoreUrlKey, @@ -98,18 +103,22 @@ export function simpleValidationError({ linkText: null, resources: { learnMoreUrlKey, - title: titleKey - ? { - stringKey: titleKey, - params: titleParams, - } - : undefined, - text: textKey - ? { - stringKey: textKey, - params: textParams, - } - : undefined, + title: + titleKey != null || titleFallback != null + ? { + stringKey: titleKey, + params: titleParams, + fallback: titleFallback, + } + : undefined, + text: + textKey != null || textFallback != null + ? { + stringKey: textKey, + params: textParams, + fallback: textFallback, + } + : undefined, action: null, }, }; diff --git a/src/bonsai/ontology.ts b/src/bonsai/ontology.ts index e2dca96bbb..a0cc7c25b2 100644 --- a/src/bonsai/ontology.ts +++ b/src/bonsai/ontology.ts @@ -12,10 +12,17 @@ import { IndexerWsTradesUpdateObject } from '@/types/indexer/indexerManual'; import { type RootState } from '@/state/_store'; import { getCurrentMarketId } from '@/state/currentMarketSelectors'; +import { SpotApiPortfolioTradesResponse, SpotApiTokenInfoObject } from '@/clients/spotApi'; +import { + SpotApiWsWalletBalanceObject, + SpotApiWsWalletPositionObject, + SpotApiWsWalletPositionsUpdate, +} from '@/lib/streaming/walletPositionsStreaming'; import { RecordValueType } from '@/lib/typeUtils'; import { HistoricalFundingObject } from './calculators/funding'; import { AdjustIsolatedMarginFormFns } from './forms/adjustIsolatedMargin'; +import { SpotFormFns } from './forms/spot'; import { TradeFormFns } from './forms/trade/trade'; import { TransferFormFns } from './forms/transfers'; import { TriggerOrdersFormFns } from './forms/triggers/triggers'; @@ -89,6 +96,20 @@ import { selectCurrentMarketOrderbook, } from './selectors/orderbook'; import { selectRewardsSummary } from './selectors/rewards'; +import { + selectSpotBalances, + selectSpotPortfolioTrades, + selectSpotPortfolioTradesLoading, + selectSpotPositions, + selectSpotSolPrice, + selectSpotSolPriceLoading, + selectSpotTokenMetadata, + selectSpotTokenMetadataLoading, + selectSpotTokenPrice, + selectSpotTokenPriceLoading, + selectSpotWalletPositions, + selectSpotWalletPositionsLoading, +} from './selectors/spot'; import { selectAllMarketSummaries, selectAllMarketSummariesLoading, @@ -216,6 +237,30 @@ interface BonsaiCoreShape { }; compliance: { data: BasicSelector; loading: BasicSelector }; rewardParams: { data: BasicSelector }; + spot: { + solPrice: { + data: BasicSelector; + loading: BasicSelector; + }; + tokenPrice: { + data: BasicSelector; + loading: BasicSelector; + }; + tokenMetadata: { + data: BasicSelector; + loading: BasicSelector; + }; + walletPositions: { + data: BasicSelector; + loading: BasicSelector; + positions: BasicSelector; + tokenBalances: BasicSelector; + }; + portfolioTrades: { + data: BasicSelector; + loading: BasicSelector; + }; + }; } export const BonsaiCore: BonsaiCoreShape = { @@ -299,6 +344,30 @@ export const BonsaiCore: BonsaiCoreShape = { }, compliance: { data: selectCompliance, loading: selectComplianceLoading }, rewardParams: { data: selectRewardsSummary }, + spot: { + solPrice: { + data: selectSpotSolPrice, + loading: selectSpotSolPriceLoading, + }, + tokenPrice: { + data: selectSpotTokenPrice, + loading: selectSpotTokenPriceLoading, + }, + tokenMetadata: { + data: selectSpotTokenMetadata, + loading: selectSpotTokenMetadataLoading, + }, + walletPositions: { + data: selectSpotWalletPositions, + loading: selectSpotWalletPositionsLoading, + positions: selectSpotPositions, + tokenBalances: selectSpotBalances, + }, + portfolioTrades: { + data: selectSpotPortfolioTrades, + loading: selectSpotPortfolioTradesLoading, + }, + }, }; interface BonsaiRawShape { @@ -456,4 +525,5 @@ export const BonsaiForms = { TriggerOrdersFormFns, AdjustIsolatedMarginFormFns, TransferFormFns, + SpotFormFns, }; diff --git a/src/bonsai/rest/spot.ts b/src/bonsai/rest/spot.ts new file mode 100644 index 0000000000..fd3b3ab98b --- /dev/null +++ b/src/bonsai/rest/spot.ts @@ -0,0 +1,201 @@ +import { QueryObserver } from '@tanstack/react-query'; + +import { AppRoute } from '@/constants/routes'; +import { timeUnits } from '@/constants/time'; +import { SOL_MINT_ADDRESS } from '@/constants/tokens'; + +import { type RootStore } from '@/state/_store'; +import { getUserSolanaWalletAddress } from '@/state/accountInfoSelectors'; +import { appQueryClient } from '@/state/appQueryClient'; +import { getCurrentPath, getSpotApiEndpoint } from '@/state/appSelectors'; +import { createAppSelector } from '@/state/appTypes'; +import { + setSpotPortfolioTrades, + setSpotSolPrice, + setSpotTokenMetadata, + setSpotTokenPrice, +} from '@/state/raw'; +import { getCurrentSpotToken } from '@/state/spot'; + +import { + getSpotPortfolioTrades, + getSpotTokenMetadata, + getSpotTokenUsdPrice, +} from '@/clients/spotApi'; + +import { createStoreEffect } from '../lib/createStoreEffect'; +import { loadableIdle } from '../lib/loadable'; +import { logBonsaiError, wrapAndLogBonsaiError } from '../logs'; +import { queryResultToLoadable } from './lib/queryResultToLoadable'; +import { safeSubscribeObserver } from './lib/safeSubscribe'; + +const getSpotApiEndpointWhenOnSpotPage = createAppSelector( + [getCurrentPath, getSpotApiEndpoint, getCurrentSpotToken], + (currentPath, spotApiEndpoint, currentSpotToken) => { + if (!currentPath.startsWith(AppRoute.Spot) || !currentSpotToken) { + return null; + } + return { endpoint: spotApiEndpoint, currentSpotToken }; + } +); + +export function setUpTokenMetadataQuery(store: RootStore) { + return createStoreEffect(store, getSpotApiEndpointWhenOnSpotPage, (params) => { + if (!params) { + store.dispatch(setSpotTokenMetadata(loadableIdle())); + return () => {}; + } + + const { endpoint, currentSpotToken } = params; + + const observer = new QueryObserver(appQueryClient, { + queryKey: ['spot', 'tokenMetadata', currentSpotToken], + queryFn: wrapAndLogBonsaiError( + () => getSpotTokenMetadata(endpoint, currentSpotToken), + 'tokenMetadata' + ), + refetchInterval: timeUnits.minute * 5, + staleTime: timeUnits.minute * 5, + retryDelay: (attempt) => timeUnits.second * 3 * 2 ** attempt, + }); + + const unsubscribe = safeSubscribeObserver(observer, (result) => { + try { + store.dispatch(setSpotTokenMetadata(queryResultToLoadable(result))); + } catch (e) { + logBonsaiError( + 'setUpTokenMetadataQuery', + 'Error handling result from react query', + e, + result + ); + } + }); + + return () => { + store.dispatch(setSpotTokenMetadata(loadableIdle())); + unsubscribe(); + }; + }); +} + +export function setUpSolPriceQuery(store: RootStore) { + return createStoreEffect(store, getSpotApiEndpointWhenOnSpotPage, (params) => { + if (!params) { + store.dispatch(setSpotSolPrice(loadableIdle())); + return () => {}; + } + + const observer = new QueryObserver(appQueryClient, { + queryKey: ['spotTokenPrice', SOL_MINT_ADDRESS], + queryFn: wrapAndLogBonsaiError( + () => getSpotTokenUsdPrice(params.endpoint, SOL_MINT_ADDRESS), + 'solPrice' + ), + refetchInterval: timeUnits.second * 10, + staleTime: timeUnits.second * 10, + retry: true, + }); + + const unsubscribe = safeSubscribeObserver(observer, (result) => { + try { + store.dispatch(setSpotSolPrice(queryResultToLoadable(result))); + } catch (e) { + logBonsaiError('setUpSolPriceQuery', 'Error handling result from react query', e, result); + } + }); + + return () => { + store.dispatch(setSpotSolPrice(loadableIdle())); + unsubscribe(); + }; + }); +} + +export function setUpSpotTokenPriceQuery(store: RootStore) { + return createStoreEffect(store, getSpotApiEndpointWhenOnSpotPage, (params) => { + if (!params) { + store.dispatch(setSpotTokenPrice(loadableIdle())); + return () => {}; + } + + const observer = new QueryObserver(appQueryClient, { + queryKey: ['spotTokenPrice', params.currentSpotToken], + queryFn: wrapAndLogBonsaiError( + () => getSpotTokenUsdPrice(params.endpoint, params.currentSpotToken), + 'tokenPrice' + ), + refetchInterval: timeUnits.second * 10, + staleTime: timeUnits.second * 10, + retry: true, + }); + + const unsubscribe = safeSubscribeObserver(observer, (result) => { + try { + store.dispatch(setSpotTokenPrice(queryResultToLoadable(result))); + } catch (e) { + logBonsaiError( + 'setUpSpotTokenPriceQuery', + 'Error handling result from react query', + e, + result + ); + } + }); + + return () => { + store.dispatch(setSpotTokenPrice(loadableIdle())); + unsubscribe(); + }; + }); +} + +const getPortfolioTradesParams = createAppSelector( + [getCurrentPath, getSpotApiEndpoint, getUserSolanaWalletAddress], + (currentPath, spotApiEndpoint, walletAddress) => { + if (!currentPath.startsWith(AppRoute.Spot) || !walletAddress) { + return null; + } + return { endpoint: spotApiEndpoint, walletAddress }; + } +); + +export function setUpPortfolioTradesQuery(store: RootStore) { + return createStoreEffect(store, getPortfolioTradesParams, (params) => { + if (!params) { + store.dispatch(setSpotPortfolioTrades(loadableIdle())); + return () => {}; + } + + const { endpoint, walletAddress } = params; + + const observer = new QueryObserver(appQueryClient, { + queryKey: ['spot', 'portfolioTrades', walletAddress], + queryFn: wrapAndLogBonsaiError( + () => getSpotPortfolioTrades(endpoint, walletAddress), + 'portfolioTrades' + ), + refetchInterval: timeUnits.minute * 2, + staleTime: timeUnits.minute * 2, + retryDelay: (attempt) => timeUnits.second * 3 * 2 ** attempt, + }); + + const unsubscribe = safeSubscribeObserver(observer, (result) => { + try { + store.dispatch(setSpotPortfolioTrades(queryResultToLoadable(result))); + } catch (e) { + logBonsaiError( + 'setUpPortfolioTradesQuery', + 'Error handling result from react query', + e, + result + ); + } + }); + + return () => { + store.dispatch(setSpotPortfolioTrades(loadableIdle())); + unsubscribe(); + }; + }); +} diff --git a/src/bonsai/selectors/base.ts b/src/bonsai/selectors/base.ts index 8742b9d047..a744adf3c8 100644 --- a/src/bonsai/selectors/base.ts +++ b/src/bonsai/selectors/base.ts @@ -80,3 +80,22 @@ export const selectRawSelectedMarketLeverages = (state: RootState) => state.raw.markets.selectedMarketLeverages; export const selectRawSelectedMarketLeveragesData = (state: RootState) => state.raw.markets.selectedMarketLeverages.data; +export const selectRawSpotSolPrice = (state: RootState) => state.raw.spot.solPrice.data; +export const selectRawSpotSolPriceLoading = (state: RootState) => state.raw.spot.solPrice.status; +export const selectRawSpotTokenPrice = (state: RootState) => state.raw.spot.tokenPrice.data; +export const selectRawSpotTokenPriceLoading = (state: RootState) => + state.raw.spot.tokenPrice.status; + +export const selectRawSpotTokenMetadata = (state: RootState) => state.raw.spot.tokenMetadata.data; +export const selectRawSpotTokenMetadataLoading = (state: RootState) => + state.raw.spot.tokenMetadata.status; + +export const selectRawSpotWalletPositions = (state: RootState) => + state.raw.spot.walletPositions.data; +export const selectRawSpotWalletPositionsLoading = (state: RootState) => + state.raw.spot.walletPositions.status; + +export const selectRawSpotPortfolioTrades = (state: RootState) => + state.raw.spot.portfolioTrades.data; +export const selectRawSpotPortfolioTradesLoading = (state: RootState) => + state.raw.spot.portfolioTrades.status; diff --git a/src/bonsai/selectors/spot.ts b/src/bonsai/selectors/spot.ts new file mode 100644 index 0000000000..f5751c33ce --- /dev/null +++ b/src/bonsai/selectors/spot.ts @@ -0,0 +1,74 @@ +import { createAppSelector } from '@/state/appTypes'; + +import { + selectRawSpotPortfolioTrades, + selectRawSpotPortfolioTradesLoading, + selectRawSpotSolPrice, + selectRawSpotSolPriceLoading, + selectRawSpotTokenMetadata, + selectRawSpotTokenMetadataLoading, + selectRawSpotTokenPrice, + selectRawSpotTokenPriceLoading, + selectRawSpotWalletPositions, + selectRawSpotWalletPositionsLoading, +} from './base'; + +export const selectSpotTokenPrice = createAppSelector( + [selectRawSpotTokenPrice], + (tokenPrice) => tokenPrice?.price +); + +export const selectSpotTokenPriceLoading = createAppSelector( + [selectRawSpotTokenPriceLoading], + (loading) => loading +); + +export const selectSpotSolPrice = createAppSelector( + [selectRawSpotSolPrice], + (solPrice) => solPrice?.price +); + +export const selectSpotSolPriceLoading = createAppSelector( + [selectRawSpotSolPriceLoading], + (loading) => loading +); + +export const selectSpotTokenMetadata = createAppSelector( + [selectRawSpotTokenMetadata], + (tokenMetadata) => tokenMetadata?.tokenInfo +); + +export const selectSpotTokenMetadataLoading = createAppSelector( + [selectRawSpotTokenMetadataLoading], + (loading) => loading +); + +export const selectSpotWalletPositions = createAppSelector( + [selectRawSpotWalletPositions], + (walletPositions) => walletPositions +); + +export const selectSpotWalletPositionsLoading = createAppSelector( + [selectRawSpotWalletPositionsLoading], + (loading) => loading +); + +export const selectSpotPositions = createAppSelector( + [selectSpotWalletPositions], + (walletPositions) => walletPositions?.positions ?? [] +); + +export const selectSpotBalances = createAppSelector( + [selectSpotWalletPositions], + (walletPositions) => walletPositions?.tokenBalances ?? [] +); + +export const selectSpotPortfolioTrades = createAppSelector( + [selectRawSpotPortfolioTrades], + (portfolioTrades) => portfolioTrades ?? { trades: [], tokenData: {} } +); + +export const selectSpotPortfolioTradesLoading = createAppSelector( + [selectRawSpotPortfolioTradesLoading], + (loading) => loading +); diff --git a/src/bonsai/storeLifecycles.ts b/src/bonsai/storeLifecycles.ts index d731957cde..2238516869 100644 --- a/src/bonsai/storeLifecycles.ts +++ b/src/bonsai/storeLifecycles.ts @@ -19,6 +19,12 @@ import { setUpNobleBalanceQuery } from './rest/nobleBalance'; import { setUpOrdersQuery } from './rest/orders'; import { setUpRewardsParamsQuery, setUpRewardsTokenPriceQuery } from './rest/rewards'; import { setUpSparklinesQuery } from './rest/sparklines'; +import { + setUpPortfolioTradesQuery, + setUpSolPriceQuery, + setUpSpotTokenPriceQuery, + setUpTokenMetadataQuery, +} from './rest/spot'; import { setUpTransfersQuery } from './rest/transfers'; import { setUpAccountBalancesQuery, @@ -31,6 +37,15 @@ import { setUpMarketsFeeDiscountQuery } from './rest/validatorMarketsMetadata'; import { setUpMarkets } from './websocket/markets'; import { setUpOrderbook } from './websocket/orderbook'; import { setUpParentSubaccount } from './websocket/parentSubaccount'; +import { setUpSpotWalletPositions } from './websocket/spot'; + +const spotLifeCycles = [ + setUpSolPriceQuery, + setUpSpotTokenPriceQuery, + setUpSpotWalletPositions, + setUpTokenMetadataQuery, + setUpPortfolioTradesQuery, +]; export const storeLifecycles = [ alwaysUseCurrentNetworkClient, @@ -64,4 +79,5 @@ export const storeLifecycles = [ setUpReclaimChildSubaccountBalancesLifecycle, setUpMarketsFeeDiscountQuery, setUpAccountStakingTierQuery, + ...spotLifeCycles, ] as const; diff --git a/src/bonsai/websocket/spot.ts b/src/bonsai/websocket/spot.ts new file mode 100644 index 0000000000..a1221b958b --- /dev/null +++ b/src/bonsai/websocket/spot.ts @@ -0,0 +1,41 @@ +import { createStoreEffect } from '@/bonsai/lib/createStoreEffect'; +import { loadableIdle, loadableLoaded } from '@/bonsai/lib/loadable'; + +import { type RootStore } from '@/state/_store'; +import { getUserSolanaWalletAddress } from '@/state/accountInfoSelectors'; +import { getSpotApiEndpoint } from '@/state/appSelectors'; +import { createAppSelector } from '@/state/appTypes'; +import { setSpotWalletPositions } from '@/state/raw'; + +import { subscribeToWalletPositions } from '@/lib/streaming/walletPositionsStreaming'; + +const getWalletPositionsSubscriptionParams = createAppSelector( + [getSpotApiEndpoint, getUserSolanaWalletAddress], + (spotApiEndpoint, solanaWalletAddress) => { + if (!spotApiEndpoint || !solanaWalletAddress) { + return null; + } + + return { endpoint: spotApiEndpoint, walletAddress: solanaWalletAddress }; + } +); + +export function setUpSpotWalletPositions(store: RootStore) { + return createStoreEffect(store, getWalletPositionsSubscriptionParams, (params) => { + if (!params) { + store.dispatch(setSpotWalletPositions(loadableIdle())); + return () => {}; + } + + const { endpoint, walletAddress } = params; + + const unsubscribe = subscribeToWalletPositions(endpoint, walletAddress, (data) => { + store.dispatch(setSpotWalletPositions(loadableLoaded(data))); + }); + + return () => { + unsubscribe(); + store.dispatch(setSpotWalletPositions(loadableIdle())); + }; + }); +} diff --git a/src/clients/spotApi.ts b/src/clients/spotApi.ts new file mode 100644 index 0000000000..10c88dd310 --- /dev/null +++ b/src/clients/spotApi.ts @@ -0,0 +1,397 @@ +import { logBonsaiError } from '@/bonsai/logs'; + +import { DEFAULT_PRIORITY_FEE_LAMPORTS, DEFAULT_SLIPPAGE_BPS } from '@/constants/spot'; + +import { simpleFetch } from '@/lib/simpleFetch'; + +export enum SpotApiLandingMethod { + JITO = 'jito', + NORMAL = 'normal', + HELIUS = 'helius', + ZERO_SLOT = '0slot', + JUPITER = 'jupiter', +} + +export enum SpotApiSide { + BUY = 'buy', + SELL = 'sell', +} + +export enum SpotApiTradeRoute { + RAYDIUM = 'raydium', + METEORA = 'meteora', + PUMP_SWAP = 'pump-swap', + ORCA = 'orca', + PUMP = 'pump', + BONK = 'bonk', + LAUNCHLAB = 'launchlab', + BELIEVE = 'believe', + JUPITER = 'jupiter', +} + +export type SpotApiCreateTransactionRequest = { + account: string; + tokenMint: string; + side: SpotApiSide; + inAmount: string; + maxSlippageBps?: number; + pool: string; + tradeRoute: SpotApiTradeRoute; + priorityFeeLamports?: number; +}; + +export type SpotApiTransactionMetadataObject = { + timestamp: string; + pool: string; + route: string; + requestedRoute?: SpotApiTradeRoute; + side: SpotApiSide; + interfaceId?: string; + implementation?: string; + subRoute?: string; + fallbackUsed?: boolean; + latency?: number; + jupiterRequestId?: string; +}; + +export type SpotApiCreateTransactionResponse = { + transaction: string; + metadata: SpotApiTransactionMetadataObject; +}; + +export type SpotApiLandTransactionRequest = { + signedTransaction: string; + expectedTokenMint: string; + walletAddress?: string; + landingMethod?: SpotApiLandingMethod; + jupiterRequestId?: string; +}; + +export type SpotApiLandTransactionResponse = { + txHash: string; + landedAt: string; + confirmationMethod: string; + landingMethod: string; + side: SpotApiSide; + tokenMint: string; + walletAddress: string; + solChange: number; + tokenChange: number; + metrics: { + boughtUsd: number; + boughtAmount: number; + soldUsd: number; + soldAmount: number; + pnlUsd: number; + pnlPercent: number; + }; +}; + +export type SpotApiTokenPriceResponse = { + price: number; +}; + +export interface SpotApiTokenSocialLinksObject { + bitcointalk: string | null; + blog: string | null; + coingecko: string | null; + coinmarketcap: string | null; + discord: string | null; + email: string | null; + facebook: string | null; + github: string | null; + instagram: string | null; + linkedin: string | null; + reddit: string | null; + slack: string | null; + telegram: string | null; + twitch: string | null; + twitter: string | null; + website: string | null; + wechat: string | null; + whitepaper: string | null; + youtube: string | null; +} + +export interface SpotApiTokenInfoObject { + tokenMint?: string; + pairAddress?: string; + tradeRoute?: string; + symbol?: string; + tokenNameFull?: string; + image?: string; + tokenTimestamp?: string; + createdAt?: number; + socialLinks?: SpotApiTokenSocialLinksObject; + otherLinks?: string; + decimals?: number; + circulatingSupply?: string; + totalSupply?: string; + priceUSD?: number; + priceSOL?: number; + marketCapUSD?: number; + marketCapSOL?: number; + pricePercentChange1h?: number; + token1hPriceChange?: string; + pricePercentChange24h?: number; + token24hPriceChange?: string; + volumeUSD?: number; + tokenVolume?: string; + liquidityUSD?: number; + liquiditySOL?: number; + tokenLiquidity?: string; + token24hBuys?: number; + token24hSells?: number; + isPump?: boolean; + bondingCurveProgress?: number; + tokenFDV?: string; + isGraduating?: boolean; + tokenDexUrl?: string; + top10HoldersPercent?: number; + holders?: number; + devHoldingPercent?: number; + snipersPercent?: number; + bundlersPercent?: number; + insidersPercent?: number; +} + +export type SpotApiTokenMetadataResponse = { + tokenInfo: SpotApiTokenInfoObject; +}; + +export interface SpotApiSearchTokenInfoObject { + mint: string; + symbol: string; + name: string; + volumeUsd: number; + priceUsd: number; + priceChangePercent24h: number; + priceChangeAmount24h: number; + marketCapUSD: number; + imageSm: string | null; + imageLg: string | null; +} + +export type SpotApiTokenSearchResponse = { + tokens: SpotApiSearchTokenInfoObject[]; +}; + +export type SpotApiBarsResolution = + | '1S' + | '5S' + | '15S' + | '30S' + | '1' + | '5' + | '15' + | '30' + | '60' + | '240' + | '720' + | '1D' + | '7D'; + +export type SpotApiGetBarsQuery = { + from: number; // unix timestamp + tokenMint: string; + to?: number; // unix timestamp, defaults to current time + resolution?: SpotApiBarsResolution; +}; + +export interface SpotApiGetBarsResponseData { + buyVolume: string[]; + buyers: number[]; + buys: number[]; + c: number[]; + h: number[]; + l: number[]; + liquidity: string[]; + o: number[]; + sellVolume: string[]; + sellers: number[]; + sells: number[]; + t: number[]; + traders: number[]; + transactions: number[]; + volume: string[]; + volumeNativeToken: string[]; + s: string; + pair: Record; +} + +export type SpotApiGetBarsResponse = { + data: SpotApiGetBarsResponseData; +}; + +export interface SpotApiBarObject { + t: number; // timestamp (unix seconds) + o: number; // open + h: number; // high + l: number; // low + c: number; // close + volume: string; // volume in USD +} + +export interface SpotApiTradeObject { + id: string; + walletAddress: string; + tokenMint: string; + decimals: number; + side: SpotApiSide; + tokenAmount: number; + solAmount: number; + usdValue: number; + tokenPriceUsd: number; + txHash: string; + createdAt: string; +} + +export type SpotApiPortfolioTradesResponse = { + trades: SpotApiTradeObject[]; + tokenData: Record; +}; + +export class SpotApiClient { + private host: string; + + constructor(host: string) { + if (!host) { + logBonsaiError('SpotApiClient', 'SpotApiClient requires a host'); + } + + this.host = host; + } + + _get(endpoint: string) { + return simpleFetch(`${this.host}/${endpoint}`); + } + + _post(endpoint: string, body: unknown) { + return simpleFetch(`${this.host}/${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + } + + async getTokenPrice(mint: string) { + return this._get(`tokens/price?mint=${mint}`); + } + + async getTokenMetadata(mint: string) { + return this._get(`tokens/info?mint=${mint}`); + } + + async searchTokens(query?: string, limit = 200) { + return this._get( + `tokens/search?phrase=${encodeURIComponent(query ?? '')}&limit=${limit}` + ); + } + + async getBars(query: SpotApiGetBarsQuery) { + const params = new URLSearchParams({ + from: query.from.toString(), + tokenMint: query.tokenMint, + }); + + if (query.to) { + params.append('to', query.to.toString()); + } + + if (query.resolution) { + params.append('resolution', query.resolution); + } + + return this._get(`tokens/bars?${params.toString()}`); + } + + async createTransaction(request: SpotApiCreateTransactionRequest) { + return this._post('transactions/create', request); + } + + async landTransaction(request: SpotApiLandTransactionRequest) { + return this._post('transactions/land', request); + } + + async getPortfolioTrades(address: string) { + return this._get(`portfolio/${address}/trades`); + } + + get url() { + return this.host; + } +} + +let spotApiClient: SpotApiClient | null = null; + +const getOrCreateSpotApiClient = (apiUrl: string): SpotApiClient => { + if (!spotApiClient || spotApiClient.url !== apiUrl) { + spotApiClient = new SpotApiClient(apiUrl); + } + return spotApiClient; +}; + +export const getSpotTokenUsdPrice = async (apiUrl: string, mint: string) => { + const client = getOrCreateSpotApiClient(apiUrl); + return client.getTokenPrice(mint); +}; + +export const getSpotTokenMetadata = async (apiUrl: string, mint: string) => { + const client = getOrCreateSpotApiClient(apiUrl); + return client.getTokenMetadata(mint); +}; + +export const searchSpotTokens = async (apiUrl: string, query?: string) => { + const client = getOrCreateSpotApiClient(apiUrl); + return client.searchTokens(query); +}; + +export const createSpotTransaction = async ( + apiUrl: string, + request: SpotApiCreateTransactionRequest +) => { + const client = getOrCreateSpotApiClient(apiUrl); + + const requestWithDefaults: Required = { + ...request, + maxSlippageBps: request.maxSlippageBps ?? DEFAULT_SLIPPAGE_BPS, + priorityFeeLamports: request.priorityFeeLamports ?? DEFAULT_PRIORITY_FEE_LAMPORTS, + }; + + return client.createTransaction(requestWithDefaults); +}; + +export const landSpotTransaction = async ( + apiUrl: string, + request: SpotApiLandTransactionRequest +) => { + const client = getOrCreateSpotApiClient(apiUrl); + return client.landTransaction(request); +}; + +export const transformBarsResponseToBars = ( + response: SpotApiGetBarsResponse +): SpotApiBarObject[] => { + const { data } = response; + return data.t.map((t, i) => ({ + t, + o: data.o[i] ?? 0, + h: data.h[i] ?? 0, + l: data.l[i] ?? 0, + c: data.c[i] ?? 0, + volume: data.volume[i] ?? '0', + })); +}; + +export const getSpotBars = async (apiUrl: string, query: SpotApiGetBarsQuery) => { + const client = getOrCreateSpotApiClient(apiUrl); + const response = await client.getBars(query); + return transformBarsResponseToBars(response); +}; + +export const getSpotPortfolioTrades = async (apiUrl: string, address: string) => { + const client = getOrCreateSpotApiClient(apiUrl); + return client.getPortfolioTrades(address); +}; diff --git a/src/components/AssetIcon.tsx b/src/components/AssetIcon.tsx index a6fd04864f..ea20cc1916 100644 --- a/src/components/AssetIcon.tsx +++ b/src/components/AssetIcon.tsx @@ -1,12 +1,16 @@ -import { useState } from 'react'; +import { useContext, useState } from 'react'; import styled, { css } from 'styled-components'; import { ASSET_ICON_MAP } from '@/constants/assets'; import { CHAIN_INFO } from '@/constants/chains'; +import { LoadingContext } from '@/contexts/LoadingContext'; + import { Nullable } from '@/lib/typeUtils'; +import { LoadingAssetIcon } from './Loading/LoadingAssetIcon'; + export type AssetSymbol = keyof typeof ASSET_ICON_MAP; const Placeholder = ({ className, symbol }: { className?: string; symbol: string }) => ( @@ -23,13 +27,20 @@ export const AssetIcon = ({ symbol, className, chainId, + isLoading = false, }: { logoUrl?: Nullable; symbol?: Nullable; className?: string; chainId?: string; + isLoading?: boolean; }) => { const [isError, setIsError] = useState(false); + const isAssetIconLoading = useContext(LoadingContext); + + if (isLoading || isAssetIconLoading) { + return <$LoadingAssetIcon className={className} />; + } if (isError || (!logoUrl && !isAssetSymbol(symbol))) { return ; @@ -133,3 +144,7 @@ const $ChainIcon = styled.img` border-radius: 9px; border: 2px solid var(--asset-icon-chain-icon-borderColor, var(--color-layer-4)); `; + +const $LoadingAssetIcon = styled(LoadingAssetIcon)` + --loading-asset-icon-size: var(--asset-icon-size, 1em); +`; diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx index 78e47fa799..0403fad8bf 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -16,6 +16,7 @@ export type CopyButtonProps = { buttonType?: 'text' | 'icon' | 'default'; children?: React.ReactNode; onCopy?: () => void; + copyIconPosition?: 'start' | 'end'; } & ButtonProps; export const CopyButton = ({ @@ -23,14 +24,20 @@ export const CopyButton = ({ buttonType = 'default', children, onCopy, + copyIconPosition = buttonType === 'default' ? 'start' : 'end', ...buttonProps }: CopyButtonProps) => { const { copied, copy, tooltipString } = useCopyValue({ value, onCopy }); return buttonType === 'text' ? ( <$InlineRow onClick={copy} copied={copied}> + {copyIconPosition === 'start' && ( + <$Icon $copied={copied} iconName={copied ? IconName.Check : IconName.Copy} /> + )} {children} - <$Icon $copied={copied} iconName={copied ? IconName.Check : IconName.Copy} /> + {copyIconPosition === 'end' && ( + <$Icon $copied={copied} iconName={copied ? IconName.Check : IconName.Copy} /> + )} ) : buttonType === 'icon' ? ( @@ -45,14 +52,16 @@ export const CopyButton = ({ ) : ( ); }; + const $InlineRow = styled.div<{ copied: boolean }>` ${layoutMixins.inlineRow} cursor: pointer; diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 22bd77c37b..2d4efbfa6d 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -19,6 +19,7 @@ import { ChatIcon, CheckCircleIcon, CheckIcon, + ChefHatIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon, @@ -33,6 +34,7 @@ import { DepthChartIcon, DevicesStrokeIcon, DiscordIcon, + DollarSignIcon, DoubleChevronRightIcon, DownloadIcon, EarthIcon, @@ -51,6 +53,7 @@ import { FunkitStandardIcon, GearIcon, GearStrokeIcon, + GhostIcon, GiftboxIcon, GoogleIcon, GooglePlayIcon, @@ -85,7 +88,9 @@ import { PasskeyIcon, Pencil2Icon, PencilIcon, + PercentSignIcon, PlayIcon, + PlusCircleIcon, PlusIcon, PositionIcon, PositionPartialIcon, @@ -100,6 +105,7 @@ import { ResendIcon, RocketIcon, RoundedArrowIcon, + ScopeIcon, SearchIcon, SendIcon, SettingsIcon, @@ -109,6 +115,8 @@ import { ShowIcon, SocialLoginIcon, SocialXIcon, + SolIcon, + SolanaSimpleIcon, SparklesIcon, SpeechBubbleIcon, StarIcon, @@ -128,11 +136,15 @@ import { TriangleIcon, TrophyIcon, TryAgainIcon, + User2Icon, + UserGroupIcon, UserIcon, + UserShieldIcon, UsersIcon, ViewfinderIcon, VolumeIcon, Wallet2Icon, + Wallet3Icon, WalletIcon, WarningIcon, WebsiteIcon, @@ -148,6 +160,10 @@ import UsdcIcon from '@/icons/usdc.svg'; import { calc } from '@/lib/do'; export enum IconName { + Wallet3 = 'Wallet3', + PlusCircle = 'PlusCircle', + UserGroup = 'UserGroup', + DollarSign = 'DollarSign', AddressConnector = 'AddressConnector', Apple = 'Apple', AppleLight = 'AppleLight', @@ -163,6 +179,7 @@ export enum IconName { CautionCircleStroked = 'CautionCircleStroked', ChaosLabs = 'ChaosLabs', Chat = 'Chat', + ChefHat = 'ChefHat', Check = 'Check', CheckCircle = 'CheckCircle', ChevronLeft = 'ChevronLeft', @@ -197,6 +214,8 @@ export enum IconName { FunkitStandard = 'FunkitStandard', Gear = 'Gear', GearStroke = 'GearStroke', + Ghost = 'Ghost', + PercentSign = 'PercentSignIcon', Giftbox = 'Giftbox', Google = 'Google', GooglePlay = 'GooglePlay', @@ -251,6 +270,7 @@ export enum IconName { RewardStars = 'RewardStars', Rocket = 'Rocket', RoundedArrow = 'RoundedArrow', + Scope = 'Scope', Search = 'Search', Send = 'Send', Settings = 'Settings', @@ -292,9 +312,17 @@ export enum IconName { Withdraw = 'Withdraw', XCircle = 'XCircle', SocialX = 'SocialX', + Sol = 'Sol', + SolanaSimple = 'SolanaSimple', + User2 = 'User2', + UserShield = 'UserShield', } const icons = { + [IconName.PlusCircle]: PlusCircleIcon, + [IconName.Wallet3]: Wallet3Icon, + [IconName.UserGroup]: UserGroupIcon, + [IconName.DollarSign]: DollarSignIcon, [IconName.AddressConnector]: AddressConnectorIcon, [IconName.Apple]: AppleIcon, [IconName.AppleLight]: AppleLightIcon, @@ -310,6 +338,7 @@ const icons = { [IconName.CautionCircleStroked]: CautionCircleStrokeIcon, [IconName.ChaosLabs]: ChaosLabsIcon, [IconName.Chat]: ChatIcon, + [IconName.ChefHat]: ChefHatIcon, [IconName.Check]: CheckIcon, [IconName.CheckCircle]: CheckCircleIcon, [IconName.ChevronLeft]: ChevronLeftIcon, @@ -344,6 +373,7 @@ const icons = { [IconName.FunkitStandard]: FunkitStandardIcon, [IconName.Gear]: GearIcon, [IconName.GearStroke]: GearStrokeIcon, + [IconName.Ghost]: GhostIcon, [IconName.Giftbox]: GiftboxIcon, [IconName.Google]: GoogleIcon, [IconName.GooglePlay]: GooglePlayIcon, @@ -395,6 +425,8 @@ const icons = { [IconName.RewardStar]: undefined, [IconName.Rocket]: RocketIcon, [IconName.RoundedArrow]: RoundedArrowIcon, + [IconName.Scope]: ScopeIcon, + [IconName.PercentSign]: PercentSignIcon, [IconName.Search]: SearchIcon, [IconName.Send]: SendIcon, [IconName.Settings]: SettingsIcon, @@ -436,6 +468,10 @@ const icons = { [IconName.Withdraw]: WithdrawIcon, [IconName.XCircle]: XCircleIcon, [IconName.SocialX]: SocialXIcon, + [IconName.Sol]: SolIcon, + [IconName.SolanaSimple]: SolanaSimpleIcon, + [IconName.User2]: User2Icon, + [IconName.UserShield]: UserShieldIcon, } as Record; // we load reward-start async because it's gigantic for some reason diff --git a/src/components/InfoGrid.tsx b/src/components/InfoGrid.tsx new file mode 100644 index 0000000000..0e79fbfd3c --- /dev/null +++ b/src/components/InfoGrid.tsx @@ -0,0 +1,78 @@ +import { ReactNode } from 'react'; + +import styled from 'styled-components'; + +import { LoadingContext } from '@/contexts/LoadingContext'; + +export type InfoGridItemProps = { + // eslint-disable-next-line react/no-unused-prop-types + key: string; + label: ReactNode; + value?: ReactNode | null; +}; + +const InfoGridItem = ({ label, value }: InfoGridItemProps) => ( + <$Card> + <$Value>{value} + <$Label>{label} + +); + +type ElementProps = { + items: InfoGridItemProps[]; + isLoading?: boolean; +}; + +type StyleProps = { + className?: string; +}; + +export const InfoGrid = ({ className, items, isLoading = false }: ElementProps & StyleProps) => ( + + <$InfoGrid className={className}> + {items.map(({ key, label, value }) => ( + + ))} + + +); + +const $InfoGrid = styled.dl` + --infogrid-numColumns: 3; + + grid-auto-rows: 1fr; + gap: 0.5rem; + display: grid; + grid-template-columns: repeat(var(--infogrid-numColumns), minmax(0, 1fr)); +`; + +const $Card = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.25rem; + padding: 0.85rem; + border: solid var(--border-width) var(--color-border); + border-radius: 0.5rem; +`; + +const $Value = styled.dd` + display: flex; + font: var(--font-base-book); + color: var(--color-text-2); + + &:empty { + color: var(--color-text-0); + opacity: 0.5; + + &:after { + content: '—'; + } + } +`; + +const $Label = styled.dt` + font: var(--font-mini-book); + color: var(--color-text-0); +`; diff --git a/src/components/Loading/Loading.stories.tsx b/src/components/Loading/Loading.stories.tsx index 6d4f01b5b3..8257a76bba 100644 --- a/src/components/Loading/Loading.stories.tsx +++ b/src/components/Loading/Loading.stories.tsx @@ -4,6 +4,7 @@ import { LoadingDots, LoadingDotsProps } from '@/components/Loading/LoadingDots' import { LoadingOutput } from '@/components/Loading/LoadingOutput'; import { LoadingSpinner } from '@/components/Loading/LoadingSpinner'; +import { LoadingAssetIcon } from './LoadingAssetIcon'; import { StoryWrapper } from '.ladle/components'; export const Dots: Story = (args) => { @@ -37,3 +38,13 @@ export const Output: Story = (args) => { }; Output.args = {}; + +export const AssetIcon: Story = (args) => { + return ( + + + + ); +}; + +AssetIcon.args = {}; diff --git a/src/components/Loading/LoadingAssetIcon.tsx b/src/components/Loading/LoadingAssetIcon.tsx new file mode 100644 index 0000000000..0efa9da2b8 --- /dev/null +++ b/src/components/Loading/LoadingAssetIcon.tsx @@ -0,0 +1,27 @@ +import styled, { keyframes } from 'styled-components'; + +export const LoadingAssetIcon = styled.div` + height: var(--loading-asset-icon-size, 1em); + min-height: var(--loading-asset-icon-size, 1em); + width: var(--loading-asset-icon-size, 1em); + min-width: var(--loading-asset-icon-size, 1em); + + background: linear-gradient( + 116deg, + hsla(245, 11%, 55%, 0.4) 0%, + currentColor 50%, + hsla(245, 11%, 55%, 0.4) 100% + ); + background-size: 200% auto; + border-radius: 50%; + opacity: 0.6; + + animation: ${keyframes` + from { + background-position: 0 0; + } + to { + background-position: -200% 0; + } + `} 1.5s linear infinite; +`; diff --git a/src/components/Output.tsx b/src/components/Output.tsx index 749226a6f6..8860d96694 100644 --- a/src/components/Output.tsx +++ b/src/components/Output.tsx @@ -8,6 +8,7 @@ import styled, { css } from 'styled-components'; import { SupportedLocales } from '@/constants/localization'; import { LEVERAGE_DECIMALS, + NumberSign, PERCENT_DECIMALS, SMALL_PERCENT_DECIMALS, SMALL_USD_DECIMALS, @@ -326,6 +327,7 @@ type StyleProps = { className?: string; withBaseFont?: boolean; withSignColor?: boolean; + withSignedValueColor?: boolean; }; export type OutputProps = ElementProps & StyleProps; @@ -349,6 +351,7 @@ export const Output = ({ withParentheses, showSign = ShowSign.Negative, withSignColor = false, + withSignedValueColor, dateOptions, relativeTimeOptions = { @@ -499,6 +502,15 @@ export const Output = ({ className={className} withParentheses={withParentheses} withBaseFont={withBaseFont} + valueColor={ + withSignedValueColor + ? isNegative + ? NumberSign.Negative + : isPositive + ? NumberSign.Positive + : undefined + : undefined + } > {slotLeft} {sign && ( @@ -553,10 +565,21 @@ const $Text = styled.output<{ withParentheses?: boolean }>` --output-afterString: ')'; `} `; -const $Number = styled($Text)<{ withBaseFont?: boolean }>` +const $Number = styled($Text)<{ withBaseFont?: boolean; valueColor?: NumberSign }>` ${({ withBaseFont }) => !withBaseFont && css` font-feature-settings: var(--fontFeature-monoNumbers); `} + + ${({ valueColor }) => + valueColor === NumberSign.Positive + ? css` + color: var(--color-positive) !important; + ` + : valueColor === NumberSign.Negative + ? css` + color: var(--color-negative) !important; + ` + : ''} `; diff --git a/src/components/TabGroup.tsx b/src/components/TabGroup.tsx index 49159562f2..56d77b3cd9 100644 --- a/src/components/TabGroup.tsx +++ b/src/components/TabGroup.tsx @@ -2,6 +2,8 @@ import React, { useEffect, useRef } from 'react'; import styled from 'styled-components'; +import { layoutMixins } from '@/styles/layoutMixins'; + export interface TabOption { label: React.ReactNode; value: T; @@ -54,17 +56,21 @@ export const TabGroup = ({ value, onTabChange, options, className }: TabGrou return ( <$Container ref={containerRef} className={className}> <$ActiveTabIndicator ref={activeTabRef} /> - {options.map((option) => ( - <$Tab - disabled={option.disabled} - key={String(option.value)} - data-value={option.value} - onClick={() => onTabChange(option.value)} - $isActive={option.value === value} - > - {option.label} - - ))} + {options.map((option) => { + const isText = typeof option.label === 'string' || typeof option.label === 'number'; + + return ( + <$Tab + disabled={option.disabled} + key={String(option.value)} + data-value={option.value} + onClick={() => onTabChange(option.value)} + $isActive={option.value === value} + > + {isText ? <$TextItem>{option.label} : option.label} + + ); + })} ); }; @@ -98,7 +104,7 @@ const $Tab = styled.button<{ $isActive: boolean }>` background: transparent; border: none; border-radius: 0.5rem; - height: 3rem; + height: var(--tab-group-height, 3rem); color: ${({ $isActive }) => ($isActive ? 'var(--color-text-2)' : 'var(--color-text-0)')}; cursor: pointer; transition: color 0.2s ease-in-out; @@ -106,6 +112,9 @@ const $Tab = styled.button<{ $isActive: boolean }>` min-width: 0; font-size: 1rem; font-weight: var(--fontWeight-medium); + display: flex; + align-items: center; + justify-content: center; &:hover { color: var(--color-text-1); @@ -119,3 +128,7 @@ const $Tab = styled.button<{ $isActive: boolean }>` outline: 2px solid var(--color-accent); } `; + +const $TextItem = styled.span` + ${layoutMixins.textTruncate} +`; diff --git a/src/components/ValidationAlert.tsx b/src/components/ValidationAlert.tsx index d343b56ff1..af76d51272 100644 --- a/src/components/ValidationAlert.tsx +++ b/src/components/ValidationAlert.tsx @@ -37,14 +37,15 @@ export const ValidationAlertMessage = ({ > {stringGetter({ - key: - error.resources.text?.stringKey ?? - error.resources.title?.stringKey ?? - STRING_KEYS.UNKNOWN_ERROR, + key: error.resources.text?.stringKey ?? error.resources.title?.stringKey, params: renderParams({ params: error.resources.text?.params }) ?? renderParams({ params: error.resources.title?.params }) ?? {}, + fallback: + error.resources.text?.fallback ?? + error.resources.title?.fallback ?? + STRING_KEYS.UNKNOWN_ERROR, })} {ourLink != null ? ( <> diff --git a/src/constants/candles.ts b/src/constants/candles.ts index 7adf7ccfee..1d0b6a4ea9 100644 --- a/src/constants/candles.ts +++ b/src/constants/candles.ts @@ -1,6 +1,6 @@ import { ResolutionString } from 'public/tradingview/charting_library'; -import { SpotCandleServiceInterval } from '@/lib/tradingView/spotDatafeed/types'; +import { SpotApiBarsResolution } from '@/clients/spotApi'; import { MetadataServiceCandlesTimeframes } from './assetMetadata'; import { STRING_KEYS } from './localization'; @@ -122,6 +122,22 @@ export const RESOLUTION_CHART_CONFIGS = { '1D': { defaultRange: 2 * timeUnits.month }, } as Record; +export const SPOT_RESOLUTION_CHART_CONFIGS = { + '1S': { defaultRange: 5 * timeUnits.minute }, + '5S': { defaultRange: 10 * timeUnits.minute }, + '15S': { defaultRange: 30 * timeUnits.minute }, + '30S': { defaultRange: timeUnits.hour }, + '1': { defaultRange: 2 * timeUnits.hour }, + '5': { defaultRange: 10 * timeUnits.hour }, + '15': { defaultRange: 30 * timeUnits.hour }, + '30': { defaultRange: timeUnits.day }, + '60': { defaultRange: 6 * timeUnits.day }, + '240': { defaultRange: 24 * timeUnits.day }, + '720': { defaultRange: 48 * timeUnits.day }, + '1D': { defaultRange: 4 * timeUnits.month }, + '1W': { defaultRange: timeUnits.year }, +} as Record; + export const RESOLUTION_STRING_TO_LABEL = { '1': { value: '1', unitStringKey: STRING_KEYS.MINUTES_ABBREVIATED }, '5': { value: '5', unitStringKey: STRING_KEYS.MINUTES_ABBREVIATED }, @@ -133,7 +149,7 @@ export const RESOLUTION_STRING_TO_LABEL = { } as Record; /** - * @description ResolutionStrings used with TradingView's charting library mapped to SpotCandleServiceInterval + * @description ResolutionStrings used with TradingView's charting library mapped to SpotApiBarsResolution */ export const RESOLUTION_TO_SPOT_INTERVAL_MAP = { '1S': '1S', @@ -149,4 +165,4 @@ export const RESOLUTION_TO_SPOT_INTERVAL_MAP = { '720': '720', '1D': '1D', '1W': '7D', -} as Record; +} as Record; diff --git a/src/constants/spot.ts b/src/constants/spot.ts new file mode 100644 index 0000000000..6ab38db2e0 --- /dev/null +++ b/src/constants/spot.ts @@ -0,0 +1,8 @@ +export const DEFAULT_PRIORITY_FEE_LAMPORTS = 100000; +export const DEFAULT_SLIPPAGE_BPS = 2000; + +export const SPOT_DUST_USD_THRESHOLD = 0.01; +export const MIN_SOL_RESERVE = 0.002; +export const SOLANA_BASE_TRANSACTION_FEE = 0.000005; +export const SOL_WITHDRAWAL_POLL_INTERVAL_MS = 1000; +export const SOL_WITHDRAWAL_TIMEOUT_MS = 30000; diff --git a/src/constants/statsig.ts b/src/constants/statsig.ts index 36f90d3143..afeb6bd730 100644 --- a/src/constants/statsig.ts +++ b/src/constants/statsig.ts @@ -16,7 +16,7 @@ export enum StatsigFlags { ffSeptember2025Rewards = 'ff_rewards_sep_2025', ffTurnkeyWeb = 'ff_turnkey_web', ffSwapEnabled = 'ff_swap_ui_web', - + ffSpot = 'ff_spot', abPopupDeposit = 'ab_popup_deposit', } diff --git a/src/constants/styles/colors.ts b/src/constants/styles/colors.ts index b1e32e7822..a92fe6935c 100644 --- a/src/constants/styles/colors.ts +++ b/src/constants/styles/colors.ts @@ -32,6 +32,8 @@ type BaseColors = { green: string; red: string; + redFaded: string; + greenFaded: string; whiteFaded: string; }; diff --git a/src/constants/tokens.ts b/src/constants/tokens.ts index dae4675524..06fdd13f4c 100644 --- a/src/constants/tokens.ts +++ b/src/constants/tokens.ts @@ -27,6 +27,8 @@ export const USDC_DECIMALS = 6; export const DYDX_DECIMALS = 18; export const ETH_DECIMALS = 18; +export const SOL_MINT_ADDRESS = 'So11111111111111111111111111111111111111112'; + export type TokenForTransfer = { chainId: string; decimals: number; diff --git a/src/hooks/tradingView/useSpotChartMarketAndResolution.ts b/src/hooks/tradingView/useSpotChartMarketAndResolution.ts new file mode 100644 index 0000000000..2cab5d024f --- /dev/null +++ b/src/hooks/tradingView/useSpotChartMarketAndResolution.ts @@ -0,0 +1,70 @@ +import { useEffect, useRef } from 'react'; + +import type { ResolutionString } from 'public/tradingview/charting_library'; + +import { DEFAULT_RESOLUTION, SPOT_RESOLUTION_CHART_CONFIGS } from '@/constants/candles'; +import type { TvWidget } from '@/constants/tvchart'; + +import { useAppSelector } from '@/state/appTypes'; +import { getTvChartConfig } from '@/state/tradingViewSelectors'; + +import { getSavedResolution } from '@/lib/tradingView/utils'; + +/** + * @description Hook to handle changing spot tokens and setting chart resolution + */ + +export const useSpotChartMarketAndResolution = ({ + tokenMint, + tvWidget, +}: { + tokenMint: string; + tvWidget?: TvWidget; +}) => { + const savedTvChartConfig = useAppSelector((s) => getTvChartConfig(s, false, true)); + const savedResolution = (getSavedResolution({ savedConfig: savedTvChartConfig }) ?? + DEFAULT_RESOLUTION) as ResolutionString; + + const savedResolutionRef = useRef(savedResolution); + useEffect(() => { + savedResolutionRef.current = savedResolution; + }, [savedResolution]); + + useEffect(() => { + if (!tvWidget) return; + + tvWidget.onChartReady(() => { + const resolution = savedResolutionRef.current; + if (tokenMint !== tvWidget.activeChart().symbol()) { + tvWidget.setSymbol(tokenMint, resolution, () => {}); + } + + tvWidget + .activeChart() + .onIntervalChanged() + .subscribe(null, (newResolution) => { + setVisibleRangeForResolution(tvWidget, newResolution); + }); + + // Set visible range on initial render + setVisibleRangeForResolution(tvWidget, resolution); + }); + }, [tokenMint, tvWidget]); +}; + +const setVisibleRangeForResolution = (tvWidget: TvWidget, resolution: ResolutionString) => { + const defaultRange: number | undefined = SPOT_RESOLUTION_CHART_CONFIGS[resolution]?.defaultRange; + + if (defaultRange) { + const to = Date.now() / 1000; + const from = (Date.now() - defaultRange) / 1000; + + tvWidget.activeChart().setVisibleRange( + { + from, + to, + }, + { percentRightMargin: 10 } + ); + } +}; diff --git a/src/hooks/tradingView/useSpotTradingView.ts b/src/hooks/tradingView/useSpotTradingView.ts index ba0e59a05c..add84a20bb 100644 --- a/src/hooks/tradingView/useSpotTradingView.ts +++ b/src/hooks/tradingView/useSpotTradingView.ts @@ -14,7 +14,7 @@ import type { TvWidget } from '@/constants/tvchart'; import { useEndpointsConfig } from '@/hooks/useEndpointsConfig'; -import { useAppDispatch, useAppSelector } from '@/state/appTypes'; +import { useAppDispatch, useAppSelector, useAppStore } from '@/state/appTypes'; import { getAppColorMode, getAppTheme } from '@/state/appUiConfigsSelectors'; import { getSelectedLocale } from '@/state/localizationSelectors'; import { updateSpotChartConfig } from '@/state/tradingView'; @@ -28,18 +28,19 @@ import { useSimpleUiEnabled } from '../useSimpleUiEnabled'; export const useSpotTradingView = ({ setTvWidget, - symbol, + tokenMint, }: { setTvWidget: Dispatch>; - symbol: string; + tokenMint: string; }) => { const dispatch = useAppDispatch(); + const store = useAppStore(); const { isTablet } = useBreakpoints(); const appTheme = useAppSelector(getAppTheme); const appColorMode = useAppSelector(getAppColorMode); const selectedLocale = useAppSelector(getSelectedLocale); const isSimpleUi = useSimpleUiEnabled(); - const { spotCandleService } = useEndpointsConfig(); + const { spotApi } = useEndpointsConfig(); const savedTvChartConfig = useAppSelector((state) => getTvChartConfig(state, false, true)); @@ -49,7 +50,7 @@ export const useSpotTradingView = ({ ); useEffect(() => { - if (!symbol || !spotCandleService) { + if (!tokenMint || !spotApi) { return () => {}; } @@ -60,10 +61,10 @@ export const useSpotTradingView = ({ const options: TradingTerminalWidgetOptions = { ...widgetOptions, ...widgetOverrides, - datafeed: getSpotDatafeed(spotCandleService), + datafeed: getSpotDatafeed(store, spotApi), interval: (savedResolution ?? DEFAULT_RESOLUTION) as ResolutionString, locale: languageCode as LanguageCode, - symbol, + symbol: tokenMint, saved_data: !isEmpty(savedTvChartConfig) ? savedTvChartConfig : undefined, auto_save_delay: 1, }; @@ -83,14 +84,5 @@ export const useSpotTradingView = ({ tvChartWidget.remove(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - selectedLocale, - symbol, - spotCandleService, - setTvWidget, - isSimpleUi, - isTablet, - savedResolution, - dispatch, - ]); + }, [selectedLocale, !!tokenMint, spotApi, setTvWidget, isSimpleUi, isTablet, dispatch]); }; diff --git a/src/hooks/useAccounts.tsx b/src/hooks/useAccounts.tsx index 2337a01e88..96c17a6c3f 100644 --- a/src/hooks/useAccounts.tsx +++ b/src/hooks/useAccounts.tsx @@ -4,6 +4,7 @@ import { getLazyLocalWallet } from '@/bonsai/lib/lazyDynamicLibs'; import { BonsaiCore } from '@/bonsai/ontology'; import { type LocalWallet, NOBLE_BECH32_PREFIX, type Subaccount } from '@dydxprotocol/v4-client-js'; import { usePrivy } from '@privy-io/react-auth'; +import { Keypair } from '@solana/web3.js'; import { AES, enc } from 'crypto-js'; import { OnboardingGuard, OnboardingState } from '@/constants/account'; @@ -25,18 +26,17 @@ import { import { useTurnkeyWallet } from '@/providers/TurnkeyWalletProvider'; import { setOnboardingGuard, setOnboardingState } from '@/state/account'; -import { getGeo } from '@/state/accountSelectors'; import { getSelectedDydxChainId } from '@/state/appSelectors'; import { useAppDispatch, useAppSelector } from '@/state/appTypes'; import { clearSavedEncryptedSignature, setLocalWallet } from '@/state/wallet'; import { getSourceAccount } from '@/state/walletSelectors'; import { hdKeyManager, localWalletManager } from '@/lib/hdKeyManager'; +import { deriveSolanaKeypairFromMnemonic } from '@/lib/solanaWallet'; import { log } from '@/lib/telemetry'; import { sleep } from '@/lib/timeUtils'; import { useDydxClient } from './useDydxClient'; -import { useEnvFeatures } from './useEnvFeatures'; import { useLocalStorage } from './useLocalStorage'; import useSignForWalletDerivation from './useSignForWalletDerivation'; import { useWalletConnection } from './useWalletConnection'; @@ -53,8 +53,6 @@ export const useAccounts = () => useContext(AccountsContext)!; const useAccountsContext = () => { const dispatch = useAppDispatch(); - const geo = useAppSelector(getGeo); - const { checkForGeo } = useEnvFeatures(); const selectedDydxChainId = useAppSelector(getSelectedDydxChainId); const { endTurnkeySession } = useTurnkeyWallet(); @@ -75,10 +73,6 @@ const useAccountsContext = () => { const { ready, authenticated } = usePrivy(); - const blockedGeo = useMemo(() => { - return geo.currentlyGeoBlocked && checkForGeo; - }, [geo, checkForGeo]); - const [previousAddress, setPreviousAddress] = useState(sourceAccount.address); useEffect(() => { @@ -135,6 +129,7 @@ const useAccountsContext = () => { const [localNobleWallet, setLocalNobleWallet] = useState(); const [localOsmosisWallet, setLocalOsmosisWallet] = useState(); const [localNeutronWallet, setLocalNeutronWallet] = useState(); + const [localSolanaKeypair, setLocalSolanaKeypair] = useState(); const [hdKey, setHdKey] = useState(); @@ -145,9 +140,18 @@ const useAccountsContext = () => { [localDydxWallet] ); + const canDeriveSolanaWallet = useMemo(() => { + return sourceAccount.chain !== WalletNetworkType.Cosmos; + }, [sourceAccount.chain]); + + const solanaAddress = useMemo( + () => localSolanaKeypair?.publicKey.toBase58(), + [localSolanaKeypair] + ); + useEffect(() => { - dispatch(setLocalWallet({ address: dydxAddress, subaccountNumber: 0 })); - }, [dispatch, dydxAddress]); + dispatch(setLocalWallet({ address: dydxAddress, solanaAddress, subaccountNumber: 0 })); + }, [dispatch, dydxAddress, solanaAddress]); const nobleAddress = localNobleWallet?.address; const osmosisAddress = localOsmosisWallet?.address; @@ -172,12 +176,12 @@ const useAccountsContext = () => { const hasLocalDydxWallet = Boolean(localDydxWallet); useEffect(() => { - if (localDydxWallet && localNobleWallet) { - localWalletManager.setLocalWallet(localDydxWallet, localNobleWallet); + if (localDydxWallet && localNobleWallet && localSolanaKeypair) { + localWalletManager.setLocalWallet(localDydxWallet, localNobleWallet, localSolanaKeypair); } else { localWalletManager.clearLocalWallet(); } - }, [localDydxWallet, localNobleWallet]); + }, [localDydxWallet, localNobleWallet, localSolanaKeypair]); useEffect(() => { (async () => { @@ -186,7 +190,7 @@ const useAccountsContext = () => { * There will not be an OnboardingState.WalletConnected state, only AccountConnected or Disconnected. */ if (sourceAccount.walletInfo?.connectorType === ConnectorType.Turnkey) { - if (!hasLocalDydxWallet && sourceAccount.encryptedSignature && !blockedGeo) { + if (!hasLocalDydxWallet && sourceAccount.encryptedSignature) { try { const signature = decryptSignature(sourceAccount.encryptedSignature); await setWalletFromSignature(signature); @@ -244,7 +248,7 @@ const useAccountsContext = () => { log('useAccounts/decryptSignature', error); dispatch(clearSavedEncryptedSignature()); } - } else if (sourceAccount.encryptedSignature && !blockedGeo) { + } else if (sourceAccount.encryptedSignature) { try { const signature = decryptSignature(sourceAccount.encryptedSignature); @@ -262,7 +266,7 @@ const useAccountsContext = () => { if (!hasLocalDydxWallet) { dispatch(setOnboardingState(OnboardingState.WalletConnected)); - if (sourceAccount.encryptedSignature && !blockedGeo) { + if (sourceAccount.encryptedSignature) { try { const signature = decryptSignature(sourceAccount.encryptedSignature); await setWalletFromSignature(signature); @@ -280,13 +284,15 @@ const useAccountsContext = () => { dispatch(setOnboardingState(OnboardingState.Disconnected)); } })(); - }, [signerWagmi, isConnectedGraz, sourceAccount, hasLocalDydxWallet, blockedGeo]); + }, [signerWagmi, isConnectedGraz, sourceAccount, hasLocalDydxWallet]); useEffect(() => { const setCosmosWallets = async () => { let nobleWallet: LocalWallet | undefined; let osmosisWallet: LocalWallet | undefined; let neutronWallet: LocalWallet | undefined; + let solanaKeypair: Keypair | undefined; + if (hdKey?.mnemonic) { nobleWallet = await ( await getLazyLocalWallet() @@ -297,6 +303,7 @@ const useAccountsContext = () => { neutronWallet = await ( await getLazyLocalWallet() ).fromMnemonic(hdKey.mnemonic, NEUTRON_BECH32_PREFIX); + solanaKeypair = deriveSolanaKeypairFromMnemonic(hdKey.mnemonic); } try { @@ -326,6 +333,9 @@ const useAccountsContext = () => { if (neutronWallet !== undefined) { setLocalNeutronWallet(neutronWallet); } + if (solanaKeypair !== undefined) { + setLocalSolanaKeypair(solanaKeypair); + } } catch (error) { log('useAccounts/setCosmosWallets', error); } @@ -368,18 +378,13 @@ const useAccountsContext = () => { ); }, [dispatch, dydxSubaccounts]); - useEffect(() => { - if (blockedGeo) { - disconnect(); - } - }, [blockedGeo]); - // Disconnect wallet / accounts const disconnectLocalDydxWallet = () => { setLocalDydxWallet(undefined); setLocalNobleWallet(undefined); setLocalOsmosisWallet(undefined); setLocalNeutronWallet(undefined); + setLocalSolanaKeypair(undefined); setHdKey(undefined); hdKeyManager.clearHdkey(); }; @@ -421,6 +426,11 @@ const useAccountsContext = () => { osmosisAddress, neutronAddress, + // Solana spot accounts + solanaAddress, + localSolanaKeypair, + canDeriveSolanaWallet, + // Onboarding state saveHasAcknowledgedTerms, diff --git a/src/hooks/useComplianceState.tsx b/src/hooks/useComplianceState.tsx index 7ac5092937..bb66de6476 100644 --- a/src/hooks/useComplianceState.tsx +++ b/src/hooks/useComplianceState.tsx @@ -1,10 +1,12 @@ import { useMemo } from 'react'; import { ComplianceStatus } from '@/bonsai/types/summaryTypes'; +import { useMatch } from 'react-router-dom'; import { OnboardingState } from '@/constants/account'; import { CLOSE_ONLY_GRACE_PERIOD, ComplianceStates } from '@/constants/compliance'; import { STRING_KEYS } from '@/constants/localization'; +import { AppRoute } from '@/constants/routes'; import { Link } from '@/components/Link'; import { OutputType, formatDateOutput } from '@/components/Output'; @@ -19,6 +21,7 @@ import { import { useAppSelector } from '@/state/appTypes'; import { getSelectedLocale } from '@/state/localizationSelectors'; +import { useEnableSpot } from './useEnableSpot'; import { useEnvFeatures } from './useEnvFeatures'; import { useStringGetter } from './useStringGetter'; import { useURLConfigs } from './useURLConfigs'; @@ -32,6 +35,8 @@ export const useComplianceState = () => { const selectedLocale = useAppSelector(getSelectedLocale); const onboardingState = useAppSelector(getOnboardingState); const { checkForGeo } = useEnvFeatures(); + const isSpotPage = useMatch(`${AppRoute.Spot}/*`) != null; + const isSpotEnabled = useEnableSpot(); const complianceState = useMemo(() => { if ( @@ -98,15 +103,16 @@ export const useComplianceState = () => { const disableConnectButton = complianceState === ComplianceStates.READ_ONLY && - onboardingState === OnboardingState.Disconnected; + onboardingState === OnboardingState.Disconnected && + !isSpotEnabled; return { complianceStatus, complianceState, complianceMessage, disableConnectButton, - showRestrictionWarning: complianceState === ComplianceStates.READ_ONLY, + showRestrictionWarning: complianceState === ComplianceStates.READ_ONLY && !isSpotPage, showComplianceBanner: - complianceMessage != null || complianceState === ComplianceStates.READ_ONLY, + (complianceMessage != null || complianceState === ComplianceStates.READ_ONLY) && !isSpotPage, }; }; diff --git a/src/hooks/useCurrentSpotToken.ts b/src/hooks/useCurrentSpotToken.ts new file mode 100644 index 0000000000..d19935dc2b --- /dev/null +++ b/src/hooks/useCurrentSpotToken.ts @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; + +import { useMatch } from 'react-router-dom'; + +import { AppRoute } from '@/constants/routes'; + +import { useAppDispatch } from '@/state/appTypes'; +import { setCurrentSpotToken } from '@/state/spot'; + +export const useCurrentSpotToken = () => { + const match = useMatch(`/${AppRoute.Spot}/:symbol`); + const { symbol } = match?.params ?? {}; + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(setCurrentSpotToken(symbol)); + }, [symbol, dispatch]); + + return { currentSpotToken: symbol }; +}; diff --git a/src/hooks/useCustomNotification.ts b/src/hooks/useCustomNotification.ts index 5f34d1fcc0..35af81fc50 100644 --- a/src/hooks/useCustomNotification.ts +++ b/src/hooks/useCustomNotification.ts @@ -6,7 +6,7 @@ import { useAppDispatch } from '@/state/appTypes'; import { addCustomNotification } from '@/state/notifications'; type CustomNoticationOptions = { - id: string; + id?: string; toastDuration?: number; }; diff --git a/src/hooks/useEnableSpot.ts b/src/hooks/useEnableSpot.ts new file mode 100644 index 0000000000..6998dd8ae5 --- /dev/null +++ b/src/hooks/useEnableSpot.ts @@ -0,0 +1,12 @@ +import { StatsigFlags } from '@/constants/statsig'; + +import { testFlags } from '@/lib/testFlags'; + +import { useStatsigGateValue } from './useStatsig'; + +export const useEnableSpot = () => { + const forcedSpot = testFlags.spot; + const spotFF = useStatsigGateValue(StatsigFlags.ffSpot); + + return forcedSpot || spotFF; +}; diff --git a/src/hooks/useEndpointsConfig.ts b/src/hooks/useEndpointsConfig.ts index 9c1d6f4090..2f724102f8 100644 --- a/src/hooks/useEndpointsConfig.ts +++ b/src/hooks/useEndpointsConfig.ts @@ -17,7 +17,7 @@ export interface EndpointsConfig { stakingAPR?: string; solanaRpcUrl: string; affiliates?: string; - spotCandleService?: string; + spotApi: string; geoV2: string; } @@ -36,7 +36,7 @@ export const useEndpointsConfig = () => { stakingAPR: endpointsConfig.stakingAPR, solanaRpcUrl: endpointsConfig.solanaRpcUrl, affiliatesBaseUrl: endpointsConfig.affiliates, - spotCandleService: endpointsConfig.spotCandleService, + spotApi: endpointsConfig.spotApi, geoV2: endpointsConfig.geoV2, }; }; diff --git a/src/hooks/useNotificationTypes.tsx b/src/hooks/useNotificationTypes.tsx index 2a785fca2f..370be2e844 100644 --- a/src/hooks/useNotificationTypes.tsx +++ b/src/hooks/useNotificationTypes.tsx @@ -66,6 +66,7 @@ import { import { getSelectedLocale } from '@/state/localizationSelectors'; import { getCustomNotifications } from '@/state/notificationsSelectors'; import { getSwaps } from '@/state/swapSelectors'; +import { isSpotWithdraw } from '@/state/transfers'; import { selectTransfersByAddress } from '@/state/transfersSelectors'; import { selectIsKeplrConnected } from '@/state/walletSelectors'; @@ -330,17 +331,19 @@ export const notificationTypes: NotificationTypeConfig[] = [ const { type, status } = transfer; const id = transfer.id; - const finalAmount = formatNumberOutput( - transfer.finalAmountUsd ?? transfer.estimatedAmountUsd, - OutputType.Fiat, - { decimalSeparator, groupSeparator, selectedLocale } - ); + const finalAmount = isSpotWithdraw(transfer) + ? `${formatNumberOutput(transfer.amount, OutputType.Number, { decimalSeparator, groupSeparator, selectedLocale, fractionDigits: 4 })} SOL` + : formatNumberOutput( + transfer.finalAmountUsd ?? transfer.estimatedAmountUsd, + OutputType.Fiat, + { decimalSeparator, groupSeparator, selectedLocale } + ); const isSuccess = status === 'success'; let body: string = ''; let title: string = ''; - if (type === 'withdraw') { + if (type === 'withdraw' || type === 'spot-withdraw') { title = stringGetter({ key: isSuccess ? STRING_KEYS.WITHDRAW : STRING_KEYS.WITHDRAW_IN_PROGRESS, }); diff --git a/src/hooks/useSolanaConnection.ts b/src/hooks/useSolanaConnection.ts index 6394b0254c..e022204da0 100644 --- a/src/hooks/useSolanaConnection.ts +++ b/src/hooks/useSolanaConnection.ts @@ -1,17 +1,12 @@ -import React from 'react'; - -import { Connection as SolanaConnection } from '@solana/web3.js'; - import { useEndpointsConfig } from '@/hooks/useEndpointsConfig'; -// Create and export a single instance of Connection -export const useSolanaConnection = () => { - const endpointsConfig = useEndpointsConfig(); +import { getSolanaConnection } from '@/lib/solanaConnection'; - const connection = React.useMemo( - () => new SolanaConnection(endpointsConfig.solanaRpcUrl), - [endpointsConfig.solanaRpcUrl] - ); - - return connection; +/** + * React hook that returns a singleton Solana connection instance. + * Automatically uses the RPC URL from endpoints config. + */ +export const useSolanaConnection = () => { + const { solanaRpcUrl } = useEndpointsConfig(); + return getSolanaConnection(solanaRpcUrl); }; diff --git a/src/hooks/useSpotForm.tsx b/src/hooks/useSpotForm.tsx new file mode 100644 index 0000000000..4efadd0217 --- /dev/null +++ b/src/hooks/useSpotForm.tsx @@ -0,0 +1,165 @@ +import { useCallback, useMemo } from 'react'; + +import { SpotBuyInputType, SpotSellInputType, SpotSide } from '@/bonsai/forms/spot'; +import { ErrorType, getHighestPriorityAlert } from '@/bonsai/lib/validationErrors'; +import { BonsaiCore } from '@/bonsai/ontology'; + +import { useAccounts } from '@/hooks/useAccounts'; +import { useCustomNotification } from '@/hooks/useCustomNotification'; +import { useLocaleSeparators } from '@/hooks/useLocaleSeparators'; +import { useSpotTransactionSubmit } from '@/hooks/useSpotTransactionSubmit'; + +import { Icon, IconName } from '@/components/Icon'; +import { formatNumberOutput, OutputType } from '@/components/Output'; + +import { appQueryClient } from '@/state/appQueryClient'; +import { useAppDispatch, useAppSelector } from '@/state/appTypes'; +import { getSelectedLocale } from '@/state/localizationSelectors'; +import { spotFormActions } from '@/state/spotForm'; +import { getSpotFormSummary } from '@/state/spotFormSelectors'; + +import { SpotApiSide } from '@/clients/spotApi'; + +// TODO: spot localization + +export function useSpotForm() { + const dispatch = useAppDispatch(); + const formSummary = useAppSelector(getSpotFormSummary); + const { canDeriveSolanaWallet } = useAccounts(); + const { mutateAsync: submitTransactionMutation, isPending } = useSpotTransactionSubmit(); + const notify = useCustomNotification(); + const tokenMetadata = useAppSelector(BonsaiCore.spot.tokenMetadata.data); + const { decimal: decimalSeparator, group: groupSeparator } = useLocaleSeparators(); + const selectedLocale = useAppSelector(getSelectedLocale); + + const hasErrors = useMemo( + () => formSummary.errors.some((error) => error.type === ErrorType.error), + [formSummary.errors] + ); + + const primaryAlert = useMemo( + () => getHighestPriorityAlert(formSummary.errors), + [formSummary.errors] + ); + + const canSubmit = useMemo( + () => canDeriveSolanaWallet && !hasErrors && formSummary.summary.payload != null && !isPending, + [canDeriveSolanaWallet, formSummary.summary.payload, hasErrors, isPending] + ); + + const actions = useMemo( + () => ({ + setSide: (side: SpotSide) => dispatch(spotFormActions.setSide(side)), + setBuyInputType: (type: Parameters[0]) => + dispatch(spotFormActions.setBuyInputType(type)), + setSellInputType: (type: Parameters[0]) => + dispatch(spotFormActions.setSellInputType(type)), + setSize: (size: string) => dispatch(spotFormActions.setSize(size)), + reset: () => dispatch(spotFormActions.reset()), + }), + [dispatch] + ); + + const handleInputTypeChange = useCallback( + (side: SpotSide, type: SpotBuyInputType | SpotSellInputType) => { + const amounts = formSummary.summary.amounts; + + if (side === SpotSide.BUY) { + const nextType = type as SpotBuyInputType; + const nextSizeNum = nextType === SpotBuyInputType.USD ? amounts?.usd : amounts?.sol; + + dispatch(spotFormActions.setBuyInputType(nextType)); + dispatch(spotFormActions.setSize(nextSizeNum?.toString() ?? '')); + return; + } + + const nextType = type as SpotSellInputType; + const nextSizeNum = nextType === SpotSellInputType.USD ? amounts?.usd : amounts?.percent; + + dispatch(spotFormActions.setSellInputType(nextType)); + dispatch(spotFormActions.setSize(nextSizeNum?.toString() ?? '')); + }, + [dispatch, formSummary.summary.amounts] + ); + + const submitTransaction = useCallback(async () => { + const payload = formSummary.summary.payload; + if (!payload) { + throw new Error('No payload available'); + } + + try { + const result = await submitTransactionMutation(payload); + dispatch(spotFormActions.reset()); + + appQueryClient.invalidateQueries({ + queryKey: ['spot', 'portfolioTrades'], + exact: false, + }); + + const { landResponse } = result; + const isBuy = landResponse.side === SpotApiSide.BUY; + const tokenSymbol = tokenMetadata?.symbol ?? ''; + + const formattedTokenAmount = formatNumberOutput(landResponse.tokenChange, OutputType.Asset, { + decimalSeparator, + groupSeparator, + selectedLocale, + }); + + const formattedSolAmount = formatNumberOutput(landResponse.solChange, OutputType.Asset, { + decimalSeparator, + groupSeparator, + selectedLocale, + }); + + notify( + { + title: 'Trade Successful', + slotTitleLeft: , + body: `${isBuy ? 'Purchased' : 'Sold'} ${formattedTokenAmount} ${tokenSymbol} for ${formattedSolAmount} SOL`, + }, + { + toastDuration: 5000, + } + ); + + return result; + } catch (error) { + notify( + { + title: 'Transaction Failed', + slotTitleLeft: , + body: 'Transaction failed. Please try again.', + }, + { + toastDuration: 5000, + } + ); + throw error; + } + }, [ + dispatch, + formSummary.summary.payload, + submitTransactionMutation, + tokenMetadata?.symbol, + decimalSeparator, + groupSeparator, + selectedLocale, + notify, + ]); + + return { + state: formSummary.state, + actions, + summary: formSummary.summary, + errors: formSummary.errors, + inputData: formSummary.inputData, + hasErrors, + primaryAlert, + canSubmit, + isPending, + handleInputTypeChange, + submitTransaction, + }; +} diff --git a/src/hooks/useSpotTokenSearch.ts b/src/hooks/useSpotTokenSearch.ts new file mode 100644 index 0000000000..7c5ec5a812 --- /dev/null +++ b/src/hooks/useSpotTokenSearch.ts @@ -0,0 +1,35 @@ +import { wrapAndLogBonsaiError } from '@/bonsai/logs'; +import { useQuery } from '@tanstack/react-query'; + +import { timeUnits } from '@/constants/time'; + +import { SpotHeaderToken } from '@/pages/spot/types'; + +import { searchSpotTokens } from '@/clients/spotApi'; + +import { useDebounce } from './useDebounce'; +import { useEndpointsConfig } from './useEndpointsConfig'; + +export const useSpotTokenSearch = (query: string, debounceMs = 300) => { + const spotApiEndpoint = useEndpointsConfig().spotApi; + const debouncedQuery = useDebounce(query, debounceMs); + + return useQuery({ + queryKey: ['spotTokenSearch', debouncedQuery], + queryFn: wrapAndLogBonsaiError(async (): Promise => { + const res = await searchSpotTokens(spotApiEndpoint, debouncedQuery); + return res.tokens.map((token) => ({ + name: token.name, + symbol: token.symbol, + tokenAddress: token.mint, + change24hPercent: token.priceChangePercent24h, + marketCapUsd: token.marketCapUSD, + markPriceUsd: token.priceUsd, + logoUrl: token.imageLg ?? token.imageSm, + priceUsd: token.priceUsd, + volume24hUsd: token.volumeUsd, + })); + }, 'spotTokenSearch'), + staleTime: timeUnits.minute, + }); +}; diff --git a/src/hooks/useSpotTransactionSubmit.ts b/src/hooks/useSpotTransactionSubmit.ts new file mode 100644 index 0000000000..2458aae0ee --- /dev/null +++ b/src/hooks/useSpotTransactionSubmit.ts @@ -0,0 +1,63 @@ +import { wrapAndLogBonsaiError } from '@/bonsai/logs'; +import { VersionedTransaction } from '@solana/web3.js'; +import { useMutation } from '@tanstack/react-query'; +import bs58 from 'bs58'; + +import { + SpotApiCreateTransactionRequest, + SpotApiLandingMethod, + createSpotTransaction, + landSpotTransaction, +} from '@/clients/spotApi'; + +import { useAccounts } from './useAccounts'; +import { useEndpointsConfig } from './useEndpointsConfig'; + +export const useSpotTransactionSubmit = () => { + const { localSolanaKeypair, solanaAddress, canDeriveSolanaWallet } = useAccounts(); + const spotApiEndpoint = useEndpointsConfig().spotApi; + + return useMutation({ + mutationFn: wrapAndLogBonsaiError(async (request: SpotApiCreateTransactionRequest) => { + if (!canDeriveSolanaWallet) { + throw new Error( + 'Spot trading is not available for Cosmos wallets. Please connect an Ethereum or Solana wallet.' + ); + } + + if (!localSolanaKeypair || !solanaAddress) { + throw new Error('Solana wallet not derived. Please reconnect your wallet.'); + } + + const requestWithAccount: SpotApiCreateTransactionRequest = { + ...request, + account: solanaAddress, + }; + + // Get unsigned transaction from backend + const createResponse = await createSpotTransaction(spotApiEndpoint, requestWithAccount); + + // Deserialize and sign with derived Solana keypair + const transaction = VersionedTransaction.deserialize( + Buffer.from(createResponse.transaction, 'base64') + ); + transaction.sign([localSolanaKeypair]); + + // Submit signed transaction (backend expects base58 encoding) + const signedTransactionBase58 = bs58.encode(transaction.serialize()); + const landResponse = await landSpotTransaction(spotApiEndpoint, { + signedTransaction: signedTransactionBase58, + expectedTokenMint: request.tokenMint, + landingMethod: createResponse.metadata.jupiterRequestId + ? SpotApiLandingMethod.JUPITER + : undefined, + jupiterRequestId: createResponse.metadata.jupiterRequestId, + }); + + return { + createResponse, + landResponse, + }; + }, 'spotTransactionSubmit'), + }); +}; diff --git a/src/icons/chef-hat.svg b/src/icons/chef-hat.svg new file mode 100644 index 0000000000..faed36db90 --- /dev/null +++ b/src/icons/chef-hat.svg @@ -0,0 +1,5 @@ + + + + diff --git a/src/icons/dollar-sign.svg b/src/icons/dollar-sign.svg new file mode 100644 index 0000000000..99b4c82ce3 --- /dev/null +++ b/src/icons/dollar-sign.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/ghost.svg b/src/icons/ghost.svg new file mode 100644 index 0000000000..f1313a2f2c --- /dev/null +++ b/src/icons/ghost.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/src/icons/index.ts b/src/icons/index.ts index b72ce0d058..5a40d27d2f 100644 --- a/src/icons/index.ts +++ b/src/icons/index.ts @@ -14,6 +14,7 @@ export { default as CautionCircleIcon } from './caution-circle.svg'; export { default as ChatIcon } from './chat-bubble.svg'; export { default as CheckCircleIcon } from './check-circle.svg'; export { default as CheckIcon } from './check.svg'; +export { default as ChefHatIcon } from './chef-hat.svg'; export { default as ChevronLeftIcon } from './chevron-left.svg'; export { default as ChevronRightIcon } from './chevron-right.svg'; export { default as ClockIcon } from './clock.svg'; @@ -28,6 +29,7 @@ export { default as DepositIcon } from './deposit.svg'; export { default as DepthChartIcon } from './depth-chart.svg'; export { default as DevicesStrokeIcon } from './devices-stroke.svg'; export { default as DiscordIcon } from './discord.svg'; +export { default as DollarSignIcon } from './dollar-sign.svg'; export { default as DoubleChevronRightIcon } from './double-chevron-right.svg'; export { default as DownloadIcon } from './download.svg'; export { default as EarthIcon } from './earth.svg'; @@ -45,6 +47,7 @@ export { default as FunkitStandardIcon } from './funkit-standard.svg'; export { default as FunkitIcon } from './funkit.svg'; export { default as GearStrokeIcon } from './gear-stroke.svg'; export { default as GearIcon } from './gear.svg'; +export { default as GhostIcon } from './ghost.svg'; export { default as GiftboxIcon } from './giftbox.svg'; export { default as GooglePlayIcon } from './google-play.svg'; export { default as GovernanceIcon } from './governance.svg'; @@ -73,7 +76,9 @@ export { default as OrderbookIcon } from './orderbook.svg'; export { default as OverviewIcon } from './overview.svg'; export { default as PencilIcon } from './pencil.svg'; export { default as Pencil2Icon } from './pencil2.svg'; +export { default as PercentSignIcon } from './percent-sign.svg'; export { default as PlayIcon } from './play.svg'; +export { default as PlusCircleIcon } from './plus-circle.svg'; export { default as PlusIcon } from './plus.svg'; export { default as PortfolioIcon } from './portfolio.svg'; export { default as PositionIcon } from './position.svg'; @@ -90,6 +95,7 @@ export { default as RewardStarIcon } from './reward-star.svg'; export { default as RewardStarsIcon } from './rewards-stars.svg'; export { default as RocketIcon } from './rocket.svg'; export { default as RoundedArrowIcon } from './rounded-arrow.svg'; +export { default as ScopeIcon } from './scope.svg'; export { default as SearchIcon } from './search.svg'; export { default as SendIcon } from './send.svg'; export { default as SettingsIcon } from './settings.svg'; @@ -98,6 +104,7 @@ export { default as ShieldStrokeIcon } from './shield-stroke.svg'; export { default as ShieldIcon } from './shield.svg'; export { default as ShowIcon } from './show.svg'; export { default as SocialXIcon } from './social-x.svg'; +export { default as SolanaSimpleIcon } from './solana-simple.svg'; export { default as SparklesIcon } from './sparkles.svg'; export { default as SpeechBubbleIcon } from './speech-bubble.svg'; export { default as StarIcon } from './star.svg'; @@ -117,16 +124,21 @@ export { default as TrendingUpIcon } from './trend-up.svg'; export { default as TriangleIcon } from './triangle.svg'; export { default as TrophyIcon } from './trophy.svg'; export { default as TryAgainIcon } from './try-again.svg'; +export { default as User2Icon } from './user-2.svg'; +export { default as UserGroupIcon } from './user-group.svg'; +export { default as UserShieldIcon } from './user-shield.svg'; export { default as UserIcon } from './user.svg'; export { default as UsersIcon } from './users.svg'; export { default as ViewfinderIcon } from './viewfinder.svg'; export { default as VolumeIcon } from './volume.svg'; +export { default as Wallet3Icon } from './wallet-3.svg'; export { default as Wallet2Icon } from './wallet2.svg'; export { default as WarningIcon } from './warning.svg'; export { default as WithdrawIcon } from './withdraw.svg'; export { default as XCircleIcon } from './x-circle.svg'; // Wallets +export { default as SolIcon } from './sol.svg'; export { default as WalletIcon } from './wallet.svg'; export { default as AppleIcon } from './wallets/apple.svg'; export { default as BitkeepIcon } from './wallets/bitkeep.svg'; diff --git a/src/icons/percent-sign.svg b/src/icons/percent-sign.svg new file mode 100644 index 0000000000..cda55b0298 --- /dev/null +++ b/src/icons/percent-sign.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/plus-circle.svg b/src/icons/plus-circle.svg new file mode 100644 index 0000000000..bc777bb0c4 --- /dev/null +++ b/src/icons/plus-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/scope.svg b/src/icons/scope.svg new file mode 100644 index 0000000000..6d01b77e2c --- /dev/null +++ b/src/icons/scope.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/src/icons/sol.svg b/src/icons/sol.svg new file mode 100644 index 0000000000..5c670ac309 --- /dev/null +++ b/src/icons/sol.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/icons/solana-simple.svg b/src/icons/solana-simple.svg new file mode 100644 index 0000000000..8b40de3134 --- /dev/null +++ b/src/icons/solana-simple.svg @@ -0,0 +1,4 @@ + + + diff --git a/src/icons/user-2.svg b/src/icons/user-2.svg new file mode 100644 index 0000000000..8453f55c8c --- /dev/null +++ b/src/icons/user-2.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/src/icons/user-group.svg b/src/icons/user-group.svg new file mode 100644 index 0000000000..2b6cb8cf53 --- /dev/null +++ b/src/icons/user-group.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/icons/user-shield.svg b/src/icons/user-shield.svg new file mode 100644 index 0000000000..7322d2a9cd --- /dev/null +++ b/src/icons/user-shield.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/src/icons/wallet-3.svg b/src/icons/wallet-3.svg new file mode 100644 index 0000000000..1895796d98 --- /dev/null +++ b/src/icons/wallet-3.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/hdKeyManager.ts b/src/lib/hdKeyManager.ts index 5fab0a9269..2546880710 100644 --- a/src/lib/hdKeyManager.ts +++ b/src/lib/hdKeyManager.ts @@ -1,5 +1,6 @@ /* eslint-disable max-classes-per-file */ import { LocalWallet } from '@dydxprotocol/v4-client-js'; +import { Keypair } from '@solana/web3.js'; import { Hdkey } from '@/constants/account'; @@ -59,14 +60,21 @@ class LocalWalletManager { private localNobleWallet: LocalWallet | undefined; + private localSolanaKeypair: Keypair | undefined; + setStore(store: RootStore) { this.store = store; } - setLocalWallet(localWallet: LocalWallet, localNobleWallet: LocalWallet) { + setLocalWallet( + localWallet: LocalWallet, + localNobleWallet: LocalWallet, + localSolanaKeypair: Keypair + ) { this.localWalletNonce = this.localWalletNonce != null ? this.localWalletNonce + 1 : 0; this.localWallet = localWallet; this.localNobleWallet = localNobleWallet; + this.localSolanaKeypair = localSolanaKeypair; if (!this.store) { log('LocalWalletManager: store has not been set'); @@ -92,10 +100,19 @@ class LocalWalletManager { return this.localNobleWallet; } + getLocalSolanaKeypair(localWalletNonce: number): Keypair | undefined { + if (localWalletNonce !== this.localWalletNonce) { + return undefined; + } + + return this.localSolanaKeypair; + } + clearLocalWallet() { this.localWalletNonce = undefined; this.localWallet = undefined; this.localNobleWallet = undefined; + this.localSolanaKeypair = undefined; this.store?.dispatch(setLocalWalletNonce(undefined)); } } diff --git a/src/lib/solanaConnection.ts b/src/lib/solanaConnection.ts new file mode 100644 index 0000000000..73b8f22aea --- /dev/null +++ b/src/lib/solanaConnection.ts @@ -0,0 +1,17 @@ +import { Connection as SolanaConnection } from '@solana/web3.js'; + +let solanaConnectionInstance: SolanaConnection | null = null; +let currentRpcUrl: string | null = null; + +/** + * Gets a singleton Solana connection instance. + * Creates a new connection only if the RPC URL changes. + */ +export const getSolanaConnection = (rpcUrl: string): SolanaConnection => { + if (!solanaConnectionInstance || currentRpcUrl !== rpcUrl) { + solanaConnectionInstance = new SolanaConnection(rpcUrl); + currentRpcUrl = rpcUrl; + } + + return solanaConnectionInstance; +}; diff --git a/src/lib/solanaWallet.ts b/src/lib/solanaWallet.ts new file mode 100644 index 0000000000..782b18c4ea --- /dev/null +++ b/src/lib/solanaWallet.ts @@ -0,0 +1,26 @@ +import { mnemonicToSeedSync } from '@scure/bip39'; +import { Keypair } from '@solana/web3.js'; +import { derivePath } from 'ed25519-hd-key'; + +/** + * Derives a Solana keypair from mnemonic using Ed25519 SLIP-0010 derivation. + * + * Uses the standard Solana BIP44 path: `m/44'/501'/'/0'` + * + * This implementation matches standard Solana wallets like Phantom, Solflare, etc. + * + * @param mnemonic - BIP39 mnemonic phrase (12 or 24 words) + * @param accountIndex - Account index for HD derivation path (default: 0) + * @returns Solana Keypair with derived keys + */ +export const deriveSolanaKeypairFromMnemonic = ( + mnemonic: string, + accountIndex: number = 0 +): Keypair => { + // TODO: Move to v4-client-js to consolidate wallet derivation and reduce duplicate dependencies + const seed = mnemonicToSeedSync(mnemonic); + const path = `m/44'/501'/${accountIndex}'/0'`; + const seedHex = Buffer.from(seed).toString('hex'); + const derivedSeed = derivePath(path, seedHex).key; + return Keypair.fromSeed(new Uint8Array(derivedSeed)); +}; diff --git a/src/lib/streaming/BaseSocketIOManager.ts b/src/lib/streaming/BaseSocketIOManager.ts new file mode 100644 index 0000000000..1f2af3999c --- /dev/null +++ b/src/lib/streaming/BaseSocketIOManager.ts @@ -0,0 +1,151 @@ +import { logBonsaiError, logBonsaiInfo } from '@/bonsai/logs'; +import { io, Socket } from 'socket.io-client'; + +export abstract class BaseSocketIOManager { + protected socket: Socket | null = null; + + protected subscriptions = new Map void }>>(); + + private isConnecting = false; + + private heartbeatInterval?: NodeJS.Timeout; + + constructor( + protected url: string, + protected heartbeatConfig?: { interval: number; event: string } + ) {} + + protected connect(): void { + if (this.isConnecting || (this.socket && this.socket.connected)) return; + if (!this.url) return; + + this.isConnecting = true; + + this.socket = io(this.url, { + autoConnect: true, + transports: ['websocket'], + }); + + this.socket.on('connect', () => { + logBonsaiInfo(this.constructor.name, 'connected'); + this.isConnecting = false; + this.onConnect(); + }); + + this.socket.on('disconnect', (reason) => { + logBonsaiInfo(this.constructor.name, 'disconnected', { reason }); + this.isConnecting = false; + this.onDisconnect(); + }); + + this.socket.on('connect_error', (error) => { + logBonsaiError(this.constructor.name, 'connection error', { error }); + this.isConnecting = false; + }); + + this.setupEventListeners(); + } + + protected abstract setupEventListeners(): void; + + protected onConnect(): void { + this.startHeartbeat(); + this.resubscribeAll(); + } + + protected onDisconnect(): void { + this.stopHeartbeat(); + } + + subscribe(channel: string, callback: (data: any) => void, subscriberUID?: string): () => void { + const uid = subscriberUID ?? crypto.randomUUID(); + + let handlers = this.subscriptions.get(channel); + if (!handlers) { + handlers = []; + this.subscriptions.set(channel, handlers); + + if (this.socket?.connected) { + this.sendSubscription(channel); + } + } + + handlers.push({ id: uid, callback }); + + if (!this.socket) { + this.connect(); + } + + return () => this.unsubscribe(uid); + } + + unsubscribe(subscriberUID: string): void { + const subscriptionEntries = Array.from(this.subscriptions.entries()); + + subscriptionEntries.find(([channel, handlers]) => { + const index = handlers.findIndex((h) => h.id === subscriberUID); + if (index !== -1) { + handlers.splice(index, 1); + + if (handlers.length === 0) { + this.subscriptions.delete(channel); + if (this.socket?.connected) { + this.sendUnsubscription(channel); + } + } + return true; + } + return false; + }); + } + + disconnect(): void { + this.stopHeartbeat(); + this.socket?.disconnect(); + this.socket = null; + this.subscriptions.clear(); + this.isConnecting = false; + } + + protected notifyHandlers(channel: string, data: any): void { + const handlers = this.subscriptions.get(channel); + if (!handlers) return; + + handlers.forEach((handler) => { + try { + handler.callback(data); + } catch (error) { + logBonsaiError(this.constructor.name, 'handler error', { error }); + } + }); + } + + protected abstract sendSubscription(channel: string): void; + + protected abstract sendUnsubscription(channel: string): void; + + private resubscribeAll(): void { + Array.from(this.subscriptions.keys()).forEach((channel) => { + this.sendSubscription(channel); + }); + } + + private startHeartbeat(): void { + if (!this.heartbeatConfig) return; + + this.heartbeatInterval = setInterval(() => { + this.socket?.emit(this.heartbeatConfig!.event); + }, this.heartbeatConfig.interval); + } + + private stopHeartbeat(): void { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = undefined; + } + } + + get urlValue() { + return this.url; + } +} diff --git a/src/lib/streaming/spotCandleStreaming.ts b/src/lib/streaming/spotCandleStreaming.ts new file mode 100644 index 0000000000..da083fd752 --- /dev/null +++ b/src/lib/streaming/spotCandleStreaming.ts @@ -0,0 +1,120 @@ +import { SpotApiBarObject, SpotApiBarsResolution } from '@/clients/spotApi'; + +import { BaseSocketIOManager } from './BaseSocketIOManager'; + +export interface SpotCandleAggregateData { + t: number; + o: number; + h: number; + l: number; + c: number; + volume: string; + buyVolume: string; + sellVolume: string; + buyers: number; + sellers: number; + buys: number; + sells: number; + traders: number; + transactions: number; + liquidity: string; + volumeNativeToken: string; + v: string | null; +} + +export interface SpotTokenBarsUpdate { + tokenMint: string; + data: { + aggregates: { + [key: string]: { + t: number; + token: SpotCandleAggregateData; + usd: SpotCandleAggregateData; + }; + }; + eventSortKey: string; + networkId: number; + statsType: string; + timestamp: number; + tokenAddress: string; + tokenId: string; + }; +} + +const getResolutionKey = (resolution: SpotApiBarsResolution): string => { + return `r${resolution}`; +}; + +const transformAggregateToBar = (aggregate: SpotCandleAggregateData): SpotApiBarObject => { + return { + t: aggregate.t, + o: aggregate.o, + h: aggregate.h, + l: aggregate.l, + c: aggregate.c, + volume: aggregate.volume, + }; +}; + +class SpotCandleSocketManager extends BaseSocketIOManager { + protected setupEventListeners(): void { + this.socket!.on('token-bars-update', (update: SpotTokenBarsUpdate) => { + const { aggregates } = update.data; + + this.subscriptions.forEach((_, channel) => { + const [tokenMint, resolution] = channel.split(':'); + + if (tokenMint === update.tokenMint) { + const resolutionKey = getResolutionKey(resolution as SpotApiBarsResolution); + const aggregateData = aggregates[resolutionKey]; + + if (aggregateData?.usd) { + const bar = transformAggregateToBar(aggregateData.usd); + this.notifyHandlers(channel, bar); + } + } + }); + }); + } + + protected sendSubscription(channel: string): void { + const [tokenMint] = channel.split(':'); + this.socket!.emit('subscribe-token-bars', { tokenMint }); + } + + protected sendUnsubscription(channel: string): void { + const [tokenMint] = channel.split(':'); + this.socket!.emit('unsubscribe-token-bars', { tokenMint }); + } +} + +let candleManager: SpotCandleSocketManager | null = null; + +export const subscribeToSpotCandles = ( + apiUrl: string, + tokenMint: string, + resolution: SpotApiBarsResolution, + onCandleUpdate: (candle: SpotApiBarObject) => void, + subscriberUID?: string +): (() => void) => { + if (!candleManager || candleManager.urlValue !== apiUrl) { + candleManager?.disconnect(); + candleManager = new SpotCandleSocketManager(apiUrl); + } + + const channel = `${tokenMint}:${resolution}`; + return candleManager.subscribe(channel, onCandleUpdate, subscriberUID); +}; + +export const unsubscribeFromSpotStream = (subscriberUID: string) => { + if (candleManager) { + candleManager.unsubscribe(subscriberUID); + } +}; + +export const disconnectSpotStream = () => { + if (candleManager) { + candleManager.disconnect(); + candleManager = null; + } +}; diff --git a/src/lib/streaming/walletPositionsStreaming.ts b/src/lib/streaming/walletPositionsStreaming.ts new file mode 100644 index 0000000000..f2f5803832 --- /dev/null +++ b/src/lib/streaming/walletPositionsStreaming.ts @@ -0,0 +1,102 @@ +import { timeUnits } from '@/constants/time'; + +import { SpotApiTokenInfoObject } from '@/clients/spotApi'; + +import { BaseSocketIOManager } from './BaseSocketIOManager'; + +export interface SpotApiWsWalletPositionObject { + walletAddress: string; + tokenMint: string; + decimals: number; + rawBalance: string; + totalBought: number; + totalSold: number; + currentBalance: number; + totalBoughtUsd: number; + totalSoldUsd: number; + averageCostBasis: number; + realizedPnL: number; + unrealizedPnL: number; + totalPnL: number; + firstTradeAt: string; + lastTradeAt: string; + tradeCount: number; + tokenData?: SpotApiTokenInfoObject; + unrealizedValueUsd: number; +} + +export interface SpotApiWsWalletBalanceObject { + mint: string; + amount: number; + decimals: number; + rawAmount: string; + priceUsd: number; + usdValue: number; +} + +export interface SpotApiWsWalletPositionsUpdate { + walletAddress: string; + positions: SpotApiWsWalletPositionObject[]; + tokenBalances: SpotApiWsWalletBalanceObject[]; + totalPnL: number; + totalRealizedPnL: number; + totalUnrealizedPnL: number; + totalValueUsd: number; + solBalance: number; + solPriceUsd: number; + solValueUsd: number; + lastUpdated: string; +} + +const HEARTBEAT_INTERVAL = timeUnits.minute * 5; + +class WalletPositionsSocketManager extends BaseSocketIOManager { + constructor(apiUrl: string) { + super(apiUrl, { + interval: HEARTBEAT_INTERVAL, + event: 'wallet-ping', + }); + } + + protected setupEventListeners(): void { + this.socket!.on('wallet-positions-update', (data: SpotApiWsWalletPositionsUpdate) => { + this.notifyHandlers(data.walletAddress, data); + }); + } + + protected sendSubscription(walletAddress: string): void { + this.socket!.emit('subscribe-positions', { channel: walletAddress }); + } + + protected sendUnsubscription(walletAddress: string): void { + this.socket!.emit('unsubscribe-positions', { channel: walletAddress }); + } +} + +let walletManager: WalletPositionsSocketManager | null = null; + +export const subscribeToWalletPositions = ( + apiUrl: string, + walletAddress: string, + onUpdate: (data: SpotApiWsWalletPositionsUpdate) => void +): (() => void) => { + if (!walletManager || walletManager.urlValue !== apiUrl) { + walletManager?.disconnect(); + walletManager = new WalletPositionsSocketManager(apiUrl); + } + + return walletManager.subscribe(walletAddress, onUpdate); +}; + +export const unsubscribeFromWalletPositions = (subscriberUID: string) => { + if (walletManager) { + walletManager.unsubscribe(subscriberUID); + } +}; + +export const disconnectWalletPositionsStream = () => { + if (walletManager) { + walletManager.disconnect(); + walletManager = null; + } +}; diff --git a/src/lib/tradingView/spotDatafeed/candleService.ts b/src/lib/tradingView/spotDatafeed/candleService.ts deleted file mode 100644 index 88b59e9a2b..0000000000 --- a/src/lib/tradingView/spotDatafeed/candleService.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { logBonsaiError } from '@/bonsai/logs'; - -import { SpotCandleData, SpotCandleServiceQuery } from './types'; -import { transformSpotCandlesForChart } from './utils'; - -export class SpotCandleServiceClient { - private host: string; - - constructor(host: string) { - if (!host) { - logBonsaiError('SpotCandleServiceClient', 'host not configured'); - } - this.host = host; - } - - private async _get(endpoint: string, params?: Record): Promise { - const url = new URL(`${this.host}/${endpoint}`); - - if (params) { - Object.entries(params).forEach(([key, value]) => { - url.searchParams.append(key, value.toString()); - }); - } - - const response = await fetch(url.toString(), { - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - return response.json(); - } - - async getCandles(params: SpotCandleServiceQuery) { - const response = await this._get(`ohlcv/${params.token}`, { - interval: params.interval, - from: params.from.toString(), - ...(params.to && { to: params.to.toString() }), - }); - - return transformSpotCandlesForChart(response); - } - - get url() { - return this.host; - } -} - -let candleServiceClient: SpotCandleServiceClient | null = null; - -const getOrCreateCandleServiceClient = (apiUrl: string): SpotCandleServiceClient => { - if (!candleServiceClient || candleServiceClient.url !== apiUrl) { - candleServiceClient = new SpotCandleServiceClient(apiUrl); - } - return candleServiceClient; -}; - -export const getSpotCandleData = async (apiUrl: string, params: SpotCandleServiceQuery) => { - const client = getOrCreateCandleServiceClient(apiUrl); - return client.getCandles(params); -}; diff --git a/src/lib/tradingView/spotDatafeed/index.ts b/src/lib/tradingView/spotDatafeed/index.ts index 3d56b4a87d..9b796956c0 100644 --- a/src/lib/tradingView/spotDatafeed/index.ts +++ b/src/lib/tradingView/spotDatafeed/index.ts @@ -1,13 +1,22 @@ import { wrapAndLogBonsaiError } from '@/bonsai/logs'; +import { BonsaiCore } from '@/bonsai/ontology'; import type { DatafeedConfiguration, IBasicDataFeed } from 'public/tradingview/charting_library'; -import { getSpotCandleData } from './candleService'; -import { subscribeToSpotStream, unsubscribeFromSpotStream } from './streaming'; -import { SpotCandleServiceQuery } from './types'; +import { type RootStore } from '@/state/_store'; + +import { getSpotBars, SpotApiGetBarsQuery } from '@/clients/spotApi'; +import { waitForSelector } from '@/lib/asyncUtils'; +import { + subscribeToSpotCandles, + unsubscribeFromSpotStream, +} from '@/lib/streaming/spotCandleStreaming'; + import { createSpotSymbolInfo, resolutionToSpotInterval, SPOT_SUPPORTED_RESOLUTIONS, + transformSpotCandleForChart, + transformSpotCandlesForChart, } from './utils'; const configurationData: DatafeedConfiguration = { @@ -28,7 +37,7 @@ const configurationData: DatafeedConfiguration = { ], }; -export const getSpotDatafeed = (spotApiUrl: string): IBasicDataFeed => ({ +export const getSpotDatafeed = (store: RootStore, spotApiUrl: string): IBasicDataFeed => ({ onReady: (cb) => { setTimeout(() => cb(configurationData), 0); }, @@ -38,14 +47,19 @@ export const getSpotDatafeed = (spotApiUrl: string): IBasicDataFeed => ({ onResultReadyCallback([]); }, - resolveSymbol: async (tokenSymbol, onSymbolResolvedCallback) => { - const symbolInfo = createSpotSymbolInfo(tokenSymbol); + resolveSymbol: async (tokenMint, onSymbolResolvedCallback) => { + const tokenPrice = await waitForSelector(store, (s) => BonsaiCore.spot.tokenPrice.data(s)); + const symbolInfo = createSpotSymbolInfo(tokenMint, tokenPrice); setTimeout(() => onSymbolResolvedCallback(symbolInfo), 0); }, getBars: async (symbolInfo, resolution, periodParams, onHistoryCallback, onErrorCallback) => { const { from, to } = periodParams; + // Clamp to parameter to current time to prevent API from returning future placeholder candles + const currentTimeSeconds = Math.floor(Date.now() / 1000) + 1; + const clampedTo = Math.min(to, currentTimeSeconds); + if (!symbolInfo.ticker) { const error = new Error('Symbol ticker is required'); onErrorCallback(error.message); @@ -53,27 +67,26 @@ export const getSpotDatafeed = (spotApiUrl: string): IBasicDataFeed => ({ } try { - const token = symbolInfo.ticker; + const tokenMint = symbolInfo.ticker; const interval = resolutionToSpotInterval(resolution); - const fromMs = from * 1000; - const toMs = to * 1000; - - const query: SpotCandleServiceQuery = { - token, - interval, - from: fromMs, - to: toMs, + + const query: SpotApiGetBarsQuery = { + tokenMint, + resolution: interval, + from, + to: clampedTo, }; const bars = await wrapAndLogBonsaiError( - () => getSpotCandleData(spotApiUrl, query), - 'getSpotCandleData' + () => getSpotBars(spotApiUrl, query), + 'getSpotBars' )(); if (bars.length === 0) { onHistoryCallback([], { noData: true }); } else { - onHistoryCallback(bars, { noData: false }); + const chartBars = transformSpotCandlesForChart(bars); + onHistoryCallback(chartBars, { noData: false }); } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; @@ -83,8 +96,20 @@ export const getSpotDatafeed = (spotApiUrl: string): IBasicDataFeed => ({ subscribeBars: (symbolInfo, resolution, onRealtimeCallback, subscriberUID) => { if (!symbolInfo.ticker) return; - const token = symbolInfo.ticker; - subscribeToSpotStream(spotApiUrl, token, resolution, onRealtimeCallback, subscriberUID); + + const tokenMint = symbolInfo.ticker; + const interval = resolutionToSpotInterval(resolution); + + subscribeToSpotCandles( + spotApiUrl, + tokenMint, + interval, + (bar) => { + const chartBar = transformSpotCandleForChart(bar); + onRealtimeCallback(chartBar); + }, + subscriberUID + ); }, unsubscribeBars: (subscriberUID) => { diff --git a/src/lib/tradingView/spotDatafeed/streaming.ts b/src/lib/tradingView/spotDatafeed/streaming.ts deleted file mode 100644 index a75b2c658b..0000000000 --- a/src/lib/tradingView/spotDatafeed/streaming.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { logBonsaiError, logBonsaiInfo } from '@/bonsai/logs'; -import type { ResolutionString, SubscribeBarsCallback } from 'public/tradingview/charting_library'; -import { io, Socket } from 'socket.io-client'; - -import { - SpotCandleServiceInterval, - SpotStreamingHandler, - SpotStreamingSubscription, - WsSpotCandleUpdate, -} from './types'; -import { resolutionToSpotInterval, transformSpotCandleForChart } from './utils'; - -class SpotStreamingManager { - private socket: Socket | null = null; - - private subscriptions = new Map(); - - private isConnecting = false; - - constructor(private apiUrl: string) { - if (!apiUrl) { - logBonsaiError('SpotStreamingManager', 'API URL not configured'); - } - } - - private async connect(): Promise { - if (this.isConnecting || (this.socket && this.socket.connected)) { - return; - } - - if (!this.apiUrl) { - logBonsaiError('SpotStreamingManager', 'API URL not configured'); - return; - } - - this.isConnecting = true; - logBonsaiInfo('SpotStreamingManager', 'connecting', { apiUrl: this.apiUrl }); - - try { - this.socket = io(this.apiUrl, { - autoConnect: true, - transports: ['websocket'], - }); - - this.socket.on('connect', () => { - logBonsaiInfo('SpotStreamingManager', 'connected'); - this.isConnecting = false; - this.resubscribeAll(); - }); - - this.socket.on('disconnect', (reason) => { - logBonsaiInfo('SpotStreamingManager', 'disconnected', { reason }); - this.isConnecting = false; - }); - - this.socket.on('connect_error', (error) => { - logBonsaiError('SpotStreamingManager', 'connection error', { error }); - this.isConnecting = false; - }); - - this.socket.on('candle', (update: WsSpotCandleUpdate) => { - this.handleCandleUpdate(update); - }); - } catch (error) { - logBonsaiError('SpotStreamingManager', 'connection failed', { error }); - this.isConnecting = false; - } - } - - private resubscribeAll(): void { - logBonsaiInfo('SpotStreamingManager', 'resubscribing all channels', { - subscriptionCount: this.subscriptions.size, - }); - - Array.from(this.subscriptions.values()).forEach((subscription) => { - this.sendSubscription(subscription.token, subscription.interval); - }); - } - - private sendSubscription(token: string, interval: SpotCandleServiceInterval): void { - if (!this.socket || !this.socket.connected) { - logBonsaiError('SpotStreamingManager', 'socket not ready (subscribe)'); - return; - } - - const subscriptionData = { token, interval }; - - logBonsaiInfo('SpotStreamingManager', 'subscribing', subscriptionData); - this.socket.emit('subscribe', subscriptionData); - } - - private sendUnsubscription(token: string, interval: SpotCandleServiceInterval): void { - if (!this.socket || !this.socket.connected) { - logBonsaiError('SpotStreamingManager', 'socket not ready (unsubscribe)'); - return; - } - - const unsubscriptionData = { token, interval }; - - logBonsaiInfo('SpotStreamingManager', 'unsubscribing', unsubscriptionData); - this.socket.emit('unsubscribe', unsubscriptionData); - } - - private handleCandleUpdate(update: WsSpotCandleUpdate): void { - try { - const { token, interval } = update; - const channelKey = `${token}:${interval}`; - - const subscription = this.subscriptions.get(channelKey); - if (!subscription) { - return; - } - - const bar = transformSpotCandleForChart(update.candle); - - logBonsaiInfo('SpotStreamingManager', 'received candle update', { - token, - interval, - handlerCount: subscription.handlers.length, - ...bar, - }); - - subscription.handlers.forEach((handler) => { - try { - handler.callback(bar); - } catch (error) { - logBonsaiError('SpotStreamingManager', 'streaming handler error', { - error, - }); - } - }); - } catch (error) { - logBonsaiError('SpotStreamingManager', 'failed to handle candle update', { - error, - }); - } - } - - subscribe( - token: string, - resolution: ResolutionString, - onRealtimeCallback: SubscribeBarsCallback, - subscriberUID: string - ): void { - const interval = resolutionToSpotInterval(resolution); - const channelKey = `${token}:${interval}`; - - logBonsaiInfo('SpotStreamingManager', 'subscription requested', { - channelKey, - subscriberUID, - }); - - const handler: SpotStreamingHandler = { - id: subscriberUID, - callback: onRealtimeCallback, - }; - - let subscription = this.subscriptions.get(channelKey); - if (subscription) { - subscription.handlers.push(handler); - logBonsaiInfo('SpotStreamingManager', 'added handler', { channelKey, subscriberUID }); - return; - } - - subscription = { - token, - interval, - handlers: [handler], - }; - - this.subscriptions.set(channelKey, subscription); - - this.sendSubscription(token, interval); - - if (!this.socket) { - this.connect(); - } - } - - unsubscribe(subscriberUID: string): void { - logBonsaiInfo('SpotStreamingManager', 'unsubscribe requested', { subscriberUID }); - - const subscriptionEntries = Array.from(this.subscriptions.entries()); - - subscriptionEntries.find(([channelKey, subscription]) => { - const handlerIndex = subscription.handlers.findIndex((h) => h.id === subscriberUID); - - if (handlerIndex !== -1) { - subscription.handlers.splice(handlerIndex, 1); - logBonsaiInfo('SpotStreamingManager', 'removed handler', { channelKey, subscriberUID }); - - if (subscription.handlers.length === 0) { - logBonsaiInfo('SpotStreamingManager', 'unsubscribing channel', { - channelKey, - subscriberUID, - }); - this.sendUnsubscription(subscription.token, subscription.interval); - this.subscriptions.delete(channelKey); - } - return true; - } - return false; - }); - } - - disconnect(): void { - logBonsaiInfo('SpotStreamingManager', 'disconnecting'); - - if (this.socket) { - this.socket.disconnect(); - this.socket = null; - } - this.subscriptions.clear(); - this.isConnecting = false; - } - - get url() { - return this.apiUrl; - } -} - -let streamingManager: SpotStreamingManager | null = null; - -const getOrCreateStreamingManager = (apiUrl: string): SpotStreamingManager => { - if (!streamingManager || streamingManager.url !== apiUrl) { - if (streamingManager) { - streamingManager.disconnect(); - } - streamingManager = new SpotStreamingManager(apiUrl); - } - return streamingManager; -}; - -export const subscribeToSpotStream = ( - apiUrl: string, - token: string, - resolution: ResolutionString, - onRealtimeCallback: SubscribeBarsCallback, - subscriberUID: string -) => { - const manager = getOrCreateStreamingManager(apiUrl); - manager.subscribe(token, resolution, onRealtimeCallback, subscriberUID); -}; - -export const unsubscribeFromSpotStream = (subscriberUID: string) => { - if (streamingManager) { - streamingManager.unsubscribe(subscriberUID); - } -}; - -export const disconnectSpotStream = () => { - if (streamingManager) { - streamingManager.disconnect(); - streamingManager = null; - } -}; diff --git a/src/lib/tradingView/spotDatafeed/types.ts b/src/lib/tradingView/spotDatafeed/types.ts deleted file mode 100644 index e375cf8c78..0000000000 --- a/src/lib/tradingView/spotDatafeed/types.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { SubscribeBarsCallback } from 'public/tradingview/charting_library'; - -export type SpotCandleServiceInterval = - | '1S' - | '5S' - | '15S' - | '30S' - | '1' - | '5' - | '15' - | '30' - | '60' - | '240' - | '720' - | '1D' - | '7D'; - -export type SpotCandleData = { - t: number; // timestamp - o: number; // open - h: number; // high - l: number; // low - c: number; // close - v: number; // volume - v_usd: number; // volume in USD -}; - -export type SpotCandleServiceQuery = { - token: string; - interval: SpotCandleServiceInterval; - from: string | number; // ISO 8601 or UNIX milliseconds - to?: string | number; // ISO 8601 or UNIX milliseconds -}; - -export type WsSpotCandleUpdate = { - candle: SpotCandleData; - interval: SpotCandleServiceInterval; - timestamp: number; - token: string; -}; - -export type SpotStreamingSubscription = { - token: string; - interval: SpotCandleServiceInterval; - handlers: SpotStreamingHandler[]; -}; - -export type SpotStreamingHandler = { - id: string; - callback: SubscribeBarsCallback; -}; diff --git a/src/lib/tradingView/spotDatafeed/utils.ts b/src/lib/tradingView/spotDatafeed/utils.ts index 0ca3851d0e..9700bb7970 100644 --- a/src/lib/tradingView/spotDatafeed/utils.ts +++ b/src/lib/tradingView/spotDatafeed/utils.ts @@ -1,59 +1,71 @@ import { DateTime } from 'luxon'; import type { - Bar, LibrarySymbolInfo, ResolutionString, Timezone, } from 'public/tradingview/charting_library'; -import { RESOLUTION_TO_SPOT_INTERVAL_MAP } from '@/constants/candles'; +import { RESOLUTION_TO_SPOT_INTERVAL_MAP, TradingViewBar } from '@/constants/candles'; +import { SMALL_USD_DECIMALS } from '@/constants/numbers'; +import { SpotApiBarObject, SpotApiBarsResolution } from '@/clients/spotApi'; import { objectKeys } from '@/lib/objectHelpers'; -import { SpotCandleData, SpotCandleServiceInterval } from './types'; - const timezone = DateTime.local().get('zoneName') as unknown as Timezone; -// Convert TradingView resolution to spot candle service interval -export const resolutionToSpotInterval = ( - resolution: ResolutionString -): SpotCandleServiceInterval => { +// Convert TradingView resolution to spot bars resolution +export const resolutionToSpotInterval = (resolution: ResolutionString): SpotApiBarsResolution => { return RESOLUTION_TO_SPOT_INTERVAL_MAP[resolution] ?? '1D'; }; // Supported resolutions for spot charts export const SPOT_SUPPORTED_RESOLUTIONS = objectKeys(RESOLUTION_TO_SPOT_INTERVAL_MAP); -// Transform single candle item for chart consumption -export const transformSpotCandleForChart = (candle: SpotCandleData): Bar => { +// Transform single bar item for chart consumption +export const transformSpotCandleForChart = (bar: SpotApiBarObject): TradingViewBar => { return { - time: candle.t * 1000, // Convert to milliseconds - open: candle.o, - high: candle.h, - low: candle.l, - close: candle.c, - volume: candle.v_usd, + time: bar.t * 1000, // Convert to milliseconds + open: bar.o, + high: bar.h, + low: bar.l, + close: bar.c, + volume: parseFloat(bar.volume), }; }; -// Transform array of candle data for chart consumption -export const transformSpotCandlesForChart = (candles: SpotCandleData[]): Bar[] => { - return candles.map(transformSpotCandleForChart); +// Transform array of bar data for chart consumption +export const transformSpotCandlesForChart = (bars: SpotApiBarObject[]): TradingViewBar[] => { + return bars.map(transformSpotCandleForChart); +}; + +const getSpotPriceDecimals = (price?: number | null): number => { + if (!price || price <= 0) return 6; + + const magnitude = Math.floor(Math.log10(Math.abs(price))); + const decimals = Math.max(0, SMALL_USD_DECIMALS - magnitude - 1); + + return Math.min(decimals, 10); }; // Create symbol info for spot tokens -export const createSpotSymbolInfo = (tokenSymbol: string): LibrarySymbolInfo => { +export const createSpotSymbolInfo = ( + tokenMint: string, + tokenPrice?: number | null +): LibrarySymbolInfo => { + const decimals = getSpotPriceDecimals(tokenPrice); + const pricescale = 10 ** decimals; + return { - ticker: tokenSymbol, - name: tokenSymbol, - description: tokenSymbol, + ticker: tokenMint, + name: tokenMint, + description: tokenMint, type: 'crypto', session: '24x7', timezone, exchange: 'Spot', listed_exchange: 'Spot', minmov: 1, - pricescale: 1000000, + pricescale, has_intraday: true, has_daily: true, has_weekly_and_monthly: true, diff --git a/src/pages/spot/QuickButtons.tsx b/src/pages/spot/QuickButtons.tsx new file mode 100644 index 0000000000..5708179ea2 --- /dev/null +++ b/src/pages/spot/QuickButtons.tsx @@ -0,0 +1,204 @@ +import { ReactNode, useEffect, useState } from 'react'; + +import { NumericFormat } from 'react-number-format'; +import styled, { css } from 'styled-components'; + +import { Icon, IconName } from '@/components/Icon'; + +type ValidationConfig = { + min?: number; + max?: number; + decimalScale?: number; +}; + +export type QuickButtonProps = { + options: string[]; + onSelect?: (value: string) => void; + onOptionsEdit?: (options: string[]) => void; + currentValue?: string; + disabled?: boolean; + validation?: ValidationConfig; + prefix?: string; + suffix?: string; + slotRight?: ReactNode; +}; + +export const QuickButtons = ({ + options, + onSelect, + onOptionsEdit, + currentValue, + disabled, + validation, + prefix, + suffix, + slotRight, +}: QuickButtonProps) => { + const [isEditing, setIsEditing] = useState(false); + const [editValues, setEditValues] = useState(options); + + const handleEdit = () => { + setEditValues(options.map((o) => o.toString())); + setIsEditing(true); + }; + + const handleConfirmEdit = () => { + const validatedValues = editValues.map((value, i) => { + const numValue = parseFloat(value); + + if (Number.isNaN(numValue)) { + return options[i]!; + } + + let clamped = numValue; + if (validation?.min !== undefined) clamped = Math.max(clamped, validation.min); + if (validation?.max !== undefined) clamped = Math.min(clamped, validation.max); + const clampedString = clamped.toString(); + + const isDuplicate = options.includes(clampedString) && options.indexOf(clampedString) !== i; + if (isDuplicate) { + return options[i]!; + } + + return clampedString; + }); + + onOptionsEdit?.(validatedValues); + setIsEditing(false); + }; + + useEffect(() => { + setEditValues(options); + }, [options]); + + const handleInputChange = (index: number, value: string) => { + const newEditValues = [...editValues]; + newEditValues[index] = value; + setEditValues(newEditValues); + }; + + return ( + <$QuickButtonsContainer> + {options.map((option, i) => + isEditing ? ( + <$InputQuickButtonContainer key={option}> + <$QuickButtonInput + allowNegative={false} + decimalScale={validation?.decimalScale ?? 2} + disabled={disabled} + value={editValues[i]} + onValueChange={(values: { value: string }) => handleInputChange(i, values.value)} + prefix={prefix} + suffix={suffix} + /> + + ) : ( + <$QuickButtonContainer + key={option} + onClick={() => onSelect?.(option)} + type="button" + isSelected={currentValue === option} + disabled={disabled} + > + + {prefix} + {option} + {suffix} + + {slotRight} + + ) + )} + <$QuickButtonContainer + aria-label={isEditing ? 'Confirm' : 'Edit'} + type="button" + onClick={isEditing ? handleConfirmEdit : handleEdit} + disabled={disabled} + isEditing={isEditing} + > + + + + ); +}; + +const $QuickButtonInput = styled(NumericFormat)` + outline: 0; + border: 0; + min-width: 0; + background-color: transparent; + text-align: center; +`; + +const $QuickButtonsContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + height: 2.3125rem; + gap: 0.75rem; + + & > *:not(:last-child) { + flex: 1; + } + + & > *:last-child { + aspect-ratio: 1; + flex-shrink: 0; + } +`; + +const QuickButtonContainerStyles = css` + display: inline-flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 0.25rem; + height: 100%; + justify-content: center; + border-radius: 0.5rem; + border: 1px solid var(--color-layer-4); + padding: 0 0.5rem; + font: var(--font-base-medium); + color: var(--color-text-1); + min-width: 0; + + @media (prefers-reduced-motion: no-preference) { + transition: 0.3s var(--ease-out-expo); + } +`; + +const $InputQuickButtonContainer = styled.div` + ${QuickButtonContainerStyles} + + background-color: var(--color-layer-3); + + &:focus-within { + background-color: var(--color-layer-4); + border-color: var(--color-layer-5); + color: var(--color-text-2); + } +`; + +const $QuickButtonContainer = styled.button<{ isSelected?: boolean; isEditing?: boolean }>` + ${QuickButtonContainerStyles} + + ${({ isSelected }) => + isSelected && + css` + background-color: var(--color-layer-4); + `} + + ${({ isEditing }) => + isEditing && + css` + background-color: var(--color-accent-faded); + color: var(--color-accent); + border-color: var(--color-accent); + `} + + @media (hover: hover) { + &:hover { + background-color: var(--color-layer-3); + } + } +`; diff --git a/src/pages/spot/Spot.tsx b/src/pages/spot/Spot.tsx index b181887306..1a35b22327 100644 --- a/src/pages/spot/Spot.tsx +++ b/src/pages/spot/Spot.tsx @@ -1,36 +1,294 @@ -import { useParams } from 'react-router-dom'; +import { useCallback, useMemo, useState } from 'react'; + +import { SpotSide } from '@/bonsai/forms/spot'; +import { BonsaiCore } from '@/bonsai/ontology'; +import { keyBy } from 'lodash'; +import { useNavigate, useParams } from 'react-router-dom'; import styled, { css } from 'styled-components'; -import { TradeLayouts } from '@/constants/layout'; +import { + HORIZONTAL_PANEL_MAX_HEIGHT, + HORIZONTAL_PANEL_MIN_HEIGHT, + TradeLayouts, +} from '@/constants/layout'; +import { SPOT_DUST_USD_THRESHOLD } from '@/constants/spot'; + +import { useCurrentSpotToken } from '@/hooks/useCurrentSpotToken'; +import { useSpotTokenSearch } from '@/hooks/useSpotTokenSearch'; import breakpoints from '@/styles/breakpoints'; import { layoutMixins } from '@/styles/layoutMixins'; +import { IconName } from '@/components/Icon'; +import { Output, OutputType } from '@/components/Output'; import { SpotTvChart } from '@/views/charts/TradingView/SpotTvChart'; -import { useAppSelector } from '@/state/appTypes'; +import { useAppDispatch, useAppSelector } from '@/state/appTypes'; +import { setHorizontalPanelHeightPx } from '@/state/appUiConfigs'; +import { getHorizontalPanelHeightPx } from '@/state/appUiConfigsSelectors'; import { getSelectedTradeLayout } from '@/state/layoutSelectors'; +import { spotFormActions } from '@/state/spotForm'; + +import { mapIfPresent } from '@/lib/do'; +import { MustNumber } from '@/lib/numbers'; +import { isPresent } from '@/lib/typeUtils'; + +import { useResizablePanel } from '../trade/useResizablePanel'; +import { SpotHeader } from './SpotHeader'; +import { type SpotPositionItem } from './SpotHoldingsTable'; +import { SpotHorizontalPanel } from './SpotHorizontalPanel'; +import { SpotTokenInfo, TokenInfoItem, TokenInfoLink } from './SpotTokenInfo'; +import { SpotTradeForm } from './SpotTradeForm'; +import { type SpotTradeItem } from './SpotTradesTable'; +import { SpotHeaderToken } from './types'; + +// TODO: spot localization const SpotPage = () => { - const { symbol } = useParams<{ symbol: string }>(); + const { tokenMint } = useParams<{ tokenMint: string }>(); + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + const tradeLayout = useAppSelector(getSelectedTradeLayout); + const tokenMetadata = useAppSelector(BonsaiCore.spot.tokenMetadata.data); + const tokenPrice = useAppSelector(BonsaiCore.spot.tokenPrice.data); + const tokenBalances = useAppSelector(BonsaiCore.spot.walletPositions.tokenBalances); + const walletPositions = useAppSelector(BonsaiCore.spot.walletPositions.positions); + const portfolioTrades = useAppSelector(BonsaiCore.spot.portfolioTrades.data); + + const isTokenMetadataLoading = + useAppSelector(BonsaiCore.spot.tokenMetadata.loading) !== 'success'; + const isWalletPositionsLoading = + useAppSelector(BonsaiCore.spot.walletPositions.loading) !== 'success'; + const isPortfolioTradesLoading = + useAppSelector(BonsaiCore.spot.portfolioTrades.loading) !== 'success'; + + const horizontalPanelHeightPxBase = useAppSelector(getHorizontalPanelHeightPx); + const setPanelHeight = useCallback( + (h: number) => { + dispatch(setHorizontalPanelHeightPx(h)); + }, + [dispatch] + ); + const [isHorizontalOpen, setIsHorizontalOpen] = useState(true); + const { + handleMouseDown, + panelHeight: horizontalPanelHeight, + isDragging, + } = useResizablePanel(horizontalPanelHeightPxBase, setPanelHeight, { + min: HORIZONTAL_PANEL_MIN_HEIGHT, + max: HORIZONTAL_PANEL_MAX_HEIGHT, + }); + + const [searchQuery, setSearchQuery] = useState(''); + + const { data: searchResults, isPending: isSearchLoading } = useSpotTokenSearch(searchQuery); + + useCurrentSpotToken(); + + const holdings: SpotPositionItem[] = useMemo(() => { + const positionsByMint = keyBy(walletPositions, 'tokenMint'); + + const res: SpotPositionItem[] = tokenBalances + .map((tokenBalance) => { + const position = positionsByMint[tokenBalance.mint]; + + return { + holdingsAmount: tokenBalance.amount, + holdingsUsd: tokenBalance.usdValue, + tokenAddress: tokenBalance.mint, + tokenName: position?.tokenData?.tokenNameFull ?? position?.tokenData?.symbol ?? 'Unknown', + tokenSymbol: position?.tokenData?.symbol ?? 'Unknown', + tokenImage: position?.tokenData?.image, + boughtAmount: position?.totalBought, + boughtUsd: position?.totalBoughtUsd, + soldAmount: position?.totalSold, + soldUsd: position?.totalSoldUsd, + pnlUsd: position?.unrealizedPnL, + avgEntryUsd: position?.averageCostBasis, + }; + }) + .filter((holding) => holding.holdingsUsd >= SPOT_DUST_USD_THRESHOLD); + + return res; + }, [tokenBalances, walletPositions]); + + const trades: SpotTradeItem[] = useMemo(() => { + return portfolioTrades.trades.map((trade) => { + const tokenData = portfolioTrades.tokenData[trade.tokenMint]; + + return { + id: trade.id, + side: trade.side, + tokenAmount: trade.tokenAmount, + usdValue: trade.usdValue, + txHash: trade.txHash, + createdAt: trade.createdAt, + tokenSymbol: tokenData?.symbol, + tokenImage: tokenData?.image, + marketCapUsd: mapIfPresent( + tokenData?.circulatingSupply, + (circulatingSupply) => MustNumber(circulatingSupply) * trade.tokenPriceUsd + ), + }; + }); + }, [portfolioTrades]); + + const currentTokenData = useMemo(() => { + if (!tokenMetadata || tokenPrice == null || !tokenMint) return null; + + return { + name: tokenMetadata.tokenNameFull ?? tokenMetadata.symbol ?? 'Unknown', + symbol: tokenMetadata.symbol ?? 'Unknown', + tokenAddress: tokenMint, + buys24hUsd: tokenMetadata.token24hBuys, + sells24hUsd: -MustNumber(tokenMetadata.token24hSells), + change24hPercent: tokenMetadata.pricePercentChange24h, + circulatingSupply: MustNumber(tokenMetadata.circulatingSupply), + liquidityUsd: tokenMetadata.liquidityUSD, + logoUrl: tokenMetadata.image, + marketCapUsd: MustNumber(tokenMetadata.circulatingSupply) * tokenPrice, + fdvUsd: MustNumber(tokenMetadata.totalSupply) * tokenPrice, + priceUsd: tokenPrice, + totalSupply: MustNumber(tokenMetadata.totalSupply), + volume24hUsd: tokenMetadata.volumeUSD, + holders: tokenMetadata.holders, + top10HoldersPercent: mapIfPresent(tokenMetadata.top10HoldersPercent, (v) => v / 100), + devHoldingPercent: mapIfPresent(tokenMetadata.devHoldingPercent, (v) => v / 100), + snipersPercent: mapIfPresent(tokenMetadata.snipersPercent, (v) => v / 100), + bundlersPercent: mapIfPresent(tokenMetadata.bundlersPercent, (v) => v / 100), + insidersPercent: mapIfPresent(tokenMetadata.insidersPercent, (v) => v / 100), + createdAt: mapIfPresent(tokenMetadata.createdAt, (v) => new Date(v * 1000)), + }; + }, [tokenMint, tokenMetadata, tokenPrice]); + + const tokenLinks = useMemo(() => { + const { socialLinks } = tokenMetadata ?? {}; + + const links: TokenInfoLink[] = [ + { icon: IconName.Earth, url: socialLinks?.website }, + { icon: IconName.CoinMarketCap, url: socialLinks?.coinmarketcap }, + { icon: IconName.SocialX, url: socialLinks?.twitter }, + ].filter((v): v is TokenInfoLink => isPresent(v.url)); + + return links; + }, [tokenMetadata]); + + const tokenInfoItems: TokenInfoItem[] = useMemo( + () => [ + { + key: 'holders', + iconName: IconName.UserGroup, + label: 'Holders', + value: <$Output type={OutputType.CompactNumber} value={currentTokenData?.holders} />, + }, + { + key: 'top10', + iconName: IconName.User2, + label: 'Top 10', + value: <$Output type={OutputType.Percent} value={currentTokenData?.top10HoldersPercent} />, + }, + { + key: 'devHolding', + iconName: IconName.ChefHat, + label: 'Dev Holding', + value: <$Output type={OutputType.Percent} value={currentTokenData?.devHoldingPercent} />, + }, + { + key: 'snipers', + iconName: IconName.Scope, + label: 'Snipers', + value: <$Output type={OutputType.Percent} value={currentTokenData?.snipersPercent} />, + }, + { + key: 'bundlers', + iconName: IconName.Ghost, + label: 'Bundlers', + value: <$Output type={OutputType.Percent} value={currentTokenData?.bundlersPercent} />, + }, + { + key: 'insiders', + iconName: IconName.Warning, + label: 'Insiders', + value: <$Output type={OutputType.Percent} value={currentTokenData?.insidersPercent} />, + }, + ], + [ + currentTokenData?.bundlersPercent, + currentTokenData?.devHoldingPercent, + currentTokenData?.holders, + currentTokenData?.insidersPercent, + currentTokenData?.snipersPercent, + currentTokenData?.top10HoldersPercent, + ] + ); + + const handleTokenSelect = (token: SpotHeaderToken) => { + navigate(`/spot/${token.tokenAddress}`); + setSearchQuery(''); + }; + + const handlePositionSelect = (token: SpotPositionItem) => { + navigate(`/spot/${token.tokenAddress}`); + }; + + const handleTokenSearchChange = (value: string) => { + setSearchQuery(value); + }; + + const handlePositionSell = (token: SpotPositionItem) => { + dispatch(spotFormActions.setSide(SpotSide.SELL)); + navigate(`/spot/${token.tokenAddress}`); + }; + + if (!tokenMint) return null; + return ( - <$SpotLayout tradeLayout={tradeLayout}> + <$SpotLayout + tradeLayout={tradeLayout} + isHorizontalOpen={isHorizontalOpen} + horizontalPanelHeightPx={horizontalPanelHeight} + >
-
Spot Market Selector (Coming Soon)
+
- <$GridSection gridArea="Side"> -
Spot Side Panel (Coming Soon)
- + <$SideGridSection gridArea="Side"> + + + <$GridSection gridArea="Inner"> - + + {isDragging && <$CoverUpTradingView />} <$GridSection gridArea="Horizontal"> -
Spot Horizontal Panel (Coming Soon)
+ ); @@ -40,25 +298,27 @@ export default SpotPage; const $SpotLayout = styled.article<{ tradeLayout: TradeLayouts; + isHorizontalOpen: boolean; + horizontalPanelHeightPx: number; }>` + --horizontalPanel-height: ${({ horizontalPanelHeightPx }) => `${horizontalPanelHeightPx}px`}; + /* prettier-ignore */ --layout-default: 'Top Top' auto 'Inner Side' minmax(0, 1fr) - 'Horizontal Side' 200px - / 1fr var(--sidebar-width); + 'Horizontal Side' minmax(var(--tabs-height), var(--horizontalPanel-height)) + / 1fr var(--spot-sidebar-width); /* prettier-ignore */ --layout-default-desktopMedium: 'Top Side' auto 'Inner Side' minmax(0, 1fr) - 'Horizontal Side' 200px - / 1fr var(--sidebar-width); + 'Horizontal Side' minmax(var(--tabs-height), var(--horizontalPanel-height)) + / 1fr var(--spot-sidebar-width); - // Props/defaults --layout: var(--layout-default); - // Variants @media ${breakpoints.desktopMedium} { --layout: var(--layout-default-desktopMedium); } @@ -74,7 +334,12 @@ const $SpotLayout = styled.article<{ `, })[tradeLayout]} - // Rules + ${({ isHorizontalOpen }) => + !isHorizontalOpen && + css` + --horizontalPanel-height: auto !important; + `} + width: 0; min-width: 100%; height: 0; @@ -102,3 +367,19 @@ const $SpotLayout = styled.article<{ const $GridSection = styled.section<{ gridArea: string }>` grid-area: ${({ gridArea }) => gridArea}; `; + +const $SideGridSection = styled($GridSection)` + ${layoutMixins.withInnerHorizontalBorders} +`; + +const $Output = styled(Output)` + line-height: 1; +`; + +const $CoverUpTradingView = styled.div` + width: 100%; + height: 100%; + position: absolute; + z-index: 2; + background: rgba(0, 0, 0, 0.2); +`; diff --git a/src/pages/spot/SpotFormInput.tsx b/src/pages/spot/SpotFormInput.tsx new file mode 100644 index 0000000000..f260629dd6 --- /dev/null +++ b/src/pages/spot/SpotFormInput.tsx @@ -0,0 +1,208 @@ +import { forwardRef, useCallback, useId, useImperativeHandle, useMemo, useRef } from 'react'; + +import { SpotBuyInputType, SpotSellInputType, SpotSide } from '@/bonsai/forms/spot'; +import styled from 'styled-components'; + +import { AlertType } from '@/constants/alerts'; +import { ButtonAction, ButtonSize, ButtonStyle } from '@/constants/buttons'; +import { PERCENT_DECIMALS, TOKEN_DECIMALS, USD_DECIMALS } from '@/constants/numbers'; + +import { AlertMessage } from '@/components/AlertMessage'; +import { Icon, IconName } from '@/components/Icon'; +import { IconButton } from '@/components/IconButton'; +import { Input, InputProps, InputType } from '@/components/Input'; +import { Output, OutputProps, OutputType } from '@/components/Output'; +import { TabGroup, TabOption } from '@/components/TabGroup'; + +const INPUT_CONFIG_MAP = { + [SpotSide.BUY]: { + [SpotBuyInputType.SOL]: { + decimals: TOKEN_DECIMALS, + type: InputType.Number, + }, + [SpotBuyInputType.USD]: { + decimals: USD_DECIMALS, + type: InputType.Currency, + }, + }, + [SpotSide.SELL]: { + [SpotSellInputType.PERCENT]: { + decimals: PERCENT_DECIMALS, + type: InputType.Percent, + }, + [SpotSellInputType.USD]: { + decimals: USD_DECIMALS, + type: InputType.Currency, + }, + }, +} as const; + +const TAB_OPTIONS_MAP: Record[]> = { + [SpotSide.SELL]: [ + { + label: , + value: SpotSellInputType.PERCENT, + }, + { + label: , + value: SpotSellInputType.USD, + }, + ], + [SpotSide.BUY]: [ + { + label: , + value: SpotBuyInputType.USD, + }, + { + label: , + value: SpotBuyInputType.SOL, + }, + ], +}; + +export type SpotFormInputProps = { + className?: string; + tokenSymbol: string; + tokenAmount: number; + balances: { + sol: number; + token: number; + usd: number; + }; + side: SpotSide; + inputType: SpotBuyInputType | SpotSellInputType; + onInputTypeChange: (side: SpotSide, inputType: SpotBuyInputType | SpotSellInputType) => void; + validationConfig?: { + type: AlertType; + message: string; + }; +} & InputProps; + +// TODO: spot localization + +export const SpotFormInput = forwardRef( + ( + { + className, + validationConfig, + inputType, + side, + balances, + tokenSymbol, + tokenAmount, + onInputTypeChange, + ...inputProps + }, + ref + ) => { + const id = useId(); + const internalRef = useRef(null); + + useImperativeHandle(ref, () => internalRef.current!); + + const balanceOutputProps: OutputProps = useMemo(() => { + if (side === SpotSide.BUY) { + if (inputType === SpotBuyInputType.SOL) { + return { + value: balances.sol, + type: OutputType.Asset, + slotRight: ' SOL', + }; + } + + return { + value: balances.usd, + type: OutputType.CompactNumber, + slotRight: ' USD', + }; + } + + return { + value: balances.token, + type: OutputType.Asset, + slotRight: ` ${tokenSymbol}`, + }; + }, [balances.sol, balances.token, balances.usd, inputType, side, tokenSymbol]); + + const handleContainerClick = useCallback(() => { + if (!internalRef.current) return; + internalRef.current.focus(); + }, []); + + const handleInputTypeChange = useCallback( + (value: SpotBuyInputType | SpotSellInputType) => { + onInputTypeChange(side, value); + }, + [onInputTypeChange, side] + ); + + const inputConfig = + side === SpotSide.BUY + ? INPUT_CONFIG_MAP[SpotSide.BUY][inputType as SpotBuyInputType] + : INPUT_CONFIG_MAP[SpotSide.SELL][inputType as SpotSellInputType]; + + const tabGroupOptions = TAB_OPTIONS_MAP[side]; + + const mergedProps = { + ...inputProps, + ...inputConfig, + } as InputProps; + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + +
+ + + {side === SpotSide.BUY && ( + + )} +
+
+
+
+ <$Input ref={internalRef} {...mergedProps} id={id} /> + +
+
+ <$TabGroup + onTabChange={handleInputTypeChange} + options={tabGroupOptions} + value={inputType} + /> +
+
+
+ {validationConfig && ( + {validationConfig.message} + )} +
+ ); + } +); + +const $Input = styled(Input)` + --input-font: var(--font-medium-medium); +` as typeof Input; + +const $TabGroup = styled(TabGroup)` + --tab-group-height: 2rem; +` as typeof TabGroup; diff --git a/src/pages/spot/SpotHeader.tsx b/src/pages/spot/SpotHeader.tsx new file mode 100644 index 0000000000..bd1a3197ef --- /dev/null +++ b/src/pages/spot/SpotHeader.tsx @@ -0,0 +1,55 @@ +import styled from 'styled-components'; + +import { layoutMixins } from '@/styles/layoutMixins'; + +import { VerticalSeparator } from '@/components/Separator'; + +import { SpotMarketStatsRow } from './SpotMarketStatsRow'; +import { SpotMarketsDropdown } from './SpotMarketsDropdown'; +import { SpotHeaderToken } from './types'; + +export type SpotHeaderProps = { + currentToken?: SpotHeaderToken | null; + searchResults?: SpotHeaderToken[]; + isSearchLoading?: boolean; + isTokenLoading?: boolean; + onTokenSelect: (token: SpotHeaderToken) => void; + onSearchTextChange?: (value: string) => void; + className?: string; +}; + +export const SpotHeader = ({ + currentToken, + searchResults = [], + isSearchLoading, + isTokenLoading, + onTokenSelect, + onSearchTextChange, + className, +}: SpotHeaderProps) => { + return ( + <$Container className={className}> + + + + + ); +}; + +const $Container = styled.div` + ${layoutMixins.container} + ${layoutMixins.scrollAreaFadeEnd} + + display: grid; + grid-template: var(--market-info-row-height) / auto; + grid-auto-flow: column; + justify-content: start; + align-items: stretch; +`; diff --git a/src/pages/spot/SpotHoldingsTable.tsx b/src/pages/spot/SpotHoldingsTable.tsx new file mode 100644 index 0000000000..bf45b96944 --- /dev/null +++ b/src/pages/spot/SpotHoldingsTable.tsx @@ -0,0 +1,234 @@ +import { useMemo } from 'react'; + +import { ColumnSize } from '@react-types/table'; +import styled from 'styled-components'; + +import { ButtonAction, ButtonSize, ButtonStyle } from '@/constants/buttons'; +import { SMALL_USD_DECIMALS } from '@/constants/numbers'; + +import { defaultTableMixins } from '@/styles/tableMixins'; + +import { AssetIcon } from '@/components/AssetIcon'; +import { Button } from '@/components/Button'; +import { Icon, IconName } from '@/components/Icon'; +import { Output, OutputType, ShowSign } from '@/components/Output'; +import { ColumnDef, Table } from '@/components/Table'; +import { TableCell } from '@/components/Table/TableCell'; + +export interface SpotPositionItem { + tokenAddress: string; + tokenName: string; + tokenSymbol: string; + tokenImage?: string; + holdingsAmount?: number; + holdingsUsd?: number; + boughtAmount?: number; + boughtUsd?: number; + soldAmount?: number; + soldUsd?: number; + pnlUsd?: number; + avgEntryUsd?: number; +} + +export enum SpotHoldingsTableColumnKey { + Token = 'Token', + Holdings = 'Holdings', + Bought = 'Bought', + Sold = 'Sold', + PnL = 'PnL', + TpSl = 'TpSl', + Actions = 'Actions', + AvgEntry = 'AvgEntry', +} + +// TODO: spot localization + +const getColumnDef = ({ + key, + width, + onSellAction, +}: { + key: SpotHoldingsTableColumnKey; + width?: ColumnSize; + onSellAction?: (row: SpotPositionItem) => void; +}): ColumnDef => ({ + width, + ...( + { + [SpotHoldingsTableColumnKey.Token]: { + columnKey: 'token', + label: 'Token', + getCellValue: (row) => row.tokenSymbol, + renderCell: ({ tokenImage, tokenSymbol }) => ( + + } + > + {tokenSymbol} + + ), + }, + [SpotHoldingsTableColumnKey.Holdings]: { + columnKey: 'holdingsUsd', + label: 'Holdings', + getCellValue: (row) => row.holdingsUsd ?? 0, + renderCell: ({ holdingsUsd, holdingsAmount, tokenName }) => ( + + + {holdingsAmount != null && ( + + + {tokenName} + + )} + + ), + }, + [SpotHoldingsTableColumnKey.Bought]: { + columnKey: 'boughtUsd', + label: 'Bought', + getCellValue: (row) => row.boughtUsd ?? 0, + renderCell: ({ boughtUsd, boughtAmount, tokenName }) => ( + + + {boughtAmount != null && ( + + + {tokenName} + + )} + + ), + }, + [SpotHoldingsTableColumnKey.Sold]: { + columnKey: 'soldUsd', + label: 'Sold', + getCellValue: (row) => row.soldUsd ?? 0, + renderCell: ({ soldUsd, soldAmount, tokenName }) => ( + + + {soldAmount != null && ( + + + {tokenName} + + )} + + ), + }, + [SpotHoldingsTableColumnKey.AvgEntry]: { + columnKey: 'avgEntry', + label: 'Avg Entry', + getCellValue: (row) => row.avgEntryUsd ?? 0, + renderCell: ({ avgEntryUsd }) => ( + + + + ), + }, + [SpotHoldingsTableColumnKey.PnL]: { + columnKey: 'pnlUsd', + label: 'PNL', + getCellValue: (row) => row.pnlUsd ?? 0, + renderCell: ({ pnlUsd }) => ( + + + + ), + }, + [SpotHoldingsTableColumnKey.TpSl]: { + columnKey: 'tpsl', + label: 'TP/SL', + allowsSorting: false, + renderCell: () => -, + }, + [SpotHoldingsTableColumnKey.Actions]: { + columnKey: 'actions', + label: 'Actions', + allowsSorting: false, + isActionable: true, + renderCell: (row) => ( + + + + ), + }, + } satisfies Record> + )[key], +}); + +export type SpotHoldingsTableProps = { + data: SpotPositionItem[]; + columnKeys?: SpotHoldingsTableColumnKey[]; + columnWidths?: Partial>; + onRowAction?: (row: SpotPositionItem) => void; + onSellAction?: (row: SpotPositionItem) => void; +}; + +export const SpotHoldingsTable = ({ + data, + columnKeys = [ + SpotHoldingsTableColumnKey.Token, + SpotHoldingsTableColumnKey.Holdings, + SpotHoldingsTableColumnKey.Bought, + SpotHoldingsTableColumnKey.Sold, + SpotHoldingsTableColumnKey.AvgEntry, + SpotHoldingsTableColumnKey.PnL, + SpotHoldingsTableColumnKey.Actions, + ], + columnWidths, + onRowAction, + onSellAction, +}: SpotHoldingsTableProps) => { + const columns = useMemo( + () => columnKeys.map((key) => getColumnDef({ key, width: columnWidths?.[key], onSellAction })), + [columnKeys, columnWidths, onSellAction] + ); + + return ( + <$Table + tableId="spot-holdings" + data={data} + getRowKey={(row) => row.tokenAddress} + onRowAction={(_, row) => { + onRowAction?.(row); + }} + columns={columns} + paginationBehavior="showAll" + withInnerBorders + withScrollSnapColumns + withScrollSnapRows + slotEmpty={ + <> + +

Holdings are empty...

+ + } + /> + ); +}; + +const $Table = styled(Table)` + ${defaultTableMixins} +` as typeof Table; diff --git a/src/pages/spot/SpotHorizontalPanel.tsx b/src/pages/spot/SpotHorizontalPanel.tsx new file mode 100644 index 0000000000..8bc680be86 --- /dev/null +++ b/src/pages/spot/SpotHorizontalPanel.tsx @@ -0,0 +1,103 @@ +import { useMemo, useState } from 'react'; + +import styled from 'styled-components'; + +import { CollapsibleTabs } from '@/components/CollapsibleTabs'; +import { LoadingSpace } from '@/components/Loading/LoadingSpinner'; + +import { + SpotHoldingsTable, + SpotHoldingsTableProps, + type SpotPositionItem, +} from './SpotHoldingsTable'; +import { SpotTradesTable, type SpotTradeItem } from './SpotTradesTable'; + +type SpotHorizontalPanelProps = { + holdings?: SpotPositionItem[]; + trades?: SpotTradeItem[]; + isHoldingsLoading?: boolean; + isTradesLoading?: boolean; + isOpen?: boolean; + setIsOpen?: (isOpen: boolean) => void; + onRowAction?: SpotHoldingsTableProps['onRowAction']; + onSellAction?: SpotHoldingsTableProps['onSellAction']; + handleStartResize?: (e: React.MouseEvent) => void; +}; + +// TODO: spot localization + +enum PanelTabs { + Holdings = 'Holdings', + Trades = 'Trades', +} + +export const SpotHorizontalPanel = ({ + holdings = [], + trades = [], + isHoldingsLoading = false, + isTradesLoading = false, + isOpen = true, + setIsOpen, + onRowAction, + onSellAction, + handleStartResize, +}: SpotHorizontalPanelProps) => { + const [tab, setTab] = useState(PanelTabs.Holdings); + + const tabItems = useMemo( + () => [ + { + value: PanelTabs.Holdings, + label: 'Holdings', + content: isHoldingsLoading ? ( + + ) : ( + + ), + }, + { + value: PanelTabs.Trades, + label: 'Trades', + content: isTradesLoading ? : , + }, + ], + [isHoldingsLoading, holdings, onRowAction, onSellAction, isTradesLoading, trades] + ); + + return ( + <> + <$DragHandle onMouseDown={handleStartResize} /> + <$CollapsibleTabs + defaultTab={PanelTabs.Holdings} + tab={tab} + setTab={setTab} + defaultOpen={isOpen} + onOpenChange={setIsOpen} + dividerStyle="underline" + tabItems={tabItems} + /> + + ); +}; + +const $CollapsibleTabs = styled(CollapsibleTabs)` + header { + background-color: var(--color-layer-2); + } + + --trigger-active-underline-backgroundColor: var(--color-layer-2); +` as typeof CollapsibleTabs; + +const $DragHandle = styled.div` + width: 100%; + height: 0.5rem; + cursor: ns-resize; + position: absolute; + top: 0; + left: 0; + z-index: 2; +`; diff --git a/src/pages/spot/SpotMarketStatsRow.tsx b/src/pages/spot/SpotMarketStatsRow.tsx new file mode 100644 index 0000000000..774d097070 --- /dev/null +++ b/src/pages/spot/SpotMarketStatsRow.tsx @@ -0,0 +1,109 @@ +import styled from 'styled-components'; + +import { SMALL_USD_DECIMALS } from '@/constants/numbers'; + +import { layoutMixins } from '@/styles/layoutMixins'; + +import { Details, type DetailsItem } from '@/components/Details'; +import { Output, OutputType, ShowSign } from '@/components/Output'; + +import { SpotHeaderToken } from './types'; + +type SpotMarketStatsRowProps = { + stats?: SpotHeaderToken | null; + isLoading?: boolean; +}; + +// TODO: spot localization + +export const SpotMarketStatsRow = ({ stats, isLoading = false }: SpotMarketStatsRowProps) => { + const items: DetailsItem[] = [ + { + key: 'market-cap', + label: 'Market Cap', + value: , + }, + { + key: 'price', + label: 'Price', + value: ( + + ), + }, + { + key: 'fdv', + label: 'FDV', + value: , + }, + { + key: 'liquidity', + label: 'Liquidity', + value: , + }, + { + key: 'supply', + label: 'Circulating/Total Supply', + value: ( + + / + + + ), + }, + { + key: 'change', + label: '% Change 24h', + value: ( + + ), + }, + { + key: 'volume', + label: 'Volume 24h', + value: , + }, + { + key: 'buys', + label: 'Buys 24h', + value: ( + + ), + }, + { + key: 'sells', + label: 'Sells 24h', + value: ( + + ), + }, + ]; + + return <$Details items={items} layout="rowColumns" withSeparators isLoading={isLoading} />; +}; + +const $Details = styled(Details)` + ${layoutMixins.scrollArea} + ${layoutMixins.row} + isolation: isolate; + font: var(--font-mini-book); +` as typeof Details; diff --git a/src/pages/spot/SpotMarketsDropdown.tsx b/src/pages/spot/SpotMarketsDropdown.tsx new file mode 100644 index 0000000000..1be310e3eb --- /dev/null +++ b/src/pages/spot/SpotMarketsDropdown.tsx @@ -0,0 +1,290 @@ +import { useMemo, useState } from 'react'; + +import styled from 'styled-components'; + +import { SMALL_USD_DECIMALS } from '@/constants/numbers'; + +import { layoutMixins } from '@/styles/layoutMixins'; +import { popoverMixins } from '@/styles/popoverMixins'; + +import { AssetIcon } from '@/components/AssetIcon'; +import { DropdownIcon } from '@/components/DropdownIcon'; +import { Icon, IconName } from '@/components/Icon'; +import { LoadingDots } from '@/components/Loading/LoadingDots'; +import { LoadingSpace } from '@/components/Loading/LoadingSpinner'; +import { Output, OutputType, ShowSign } from '@/components/Output'; +import { Popover, TriggerType } from '@/components/Popover'; +import { SearchInput } from '@/components/SearchInput'; +import { ColumnDef, Table } from '@/components/Table'; +import { TableCell } from '@/components/Table/TableCell'; +import { Toolbar } from '@/components/Toolbar'; +import { FavoriteButton } from '@/views/tables/MarketsTable/FavoriteButton'; + +import { useAppSelector } from '@/state/appTypes'; +import { getSpotFavorites } from '@/state/appUiConfigsSelectors'; + +import { truncateAddress } from '@/lib/wallet'; + +import { SpotHeaderToken } from './types'; + +type SpotMarketsDropdownProps = { + current?: SpotHeaderToken | null; + searchResults?: SpotHeaderToken[]; + isSearchLoading?: boolean; + isTokenLoading?: boolean; + onSelect: (token: SpotHeaderToken) => void; + onSearchTextChange?: (value: string) => void; + className?: string; +}; + +// TODO: spot localization + +export const SpotMarketsDropdown = ({ + current, + searchResults = [], + isSearchLoading, + isTokenLoading, + onSelect, + onSearchTextChange, + className, +}: SpotMarketsDropdownProps) => { + const [isOpen, setIsOpen] = useState(false); + const favoritedTokenAddresses = useAppSelector(getSpotFavorites); + const favoritedSet = useMemo(() => new Set(favoritedTokenAddresses), [favoritedTokenAddresses]); + + const columns = useMemo( + () => + [ + { + columnKey: 'market', + getCellValue: (row) => row.name, + label: 'Name', + renderCell: ({ symbol, logoUrl, tokenAddress }) => ( +
+ + <$AssetIcon logoUrl={logoUrl} symbol={symbol} /> +
+

{symbol}

+ + {truncateAddress(tokenAddress, '')} + +
+
+ ), + }, + { + columnKey: 'volume24h', + getCellValue: (row) => row.volume24hUsd ?? 0, + label: 'Volume', + renderCell: ({ volume24hUsd }) => ( + + ), + }, + { + columnKey: 'price', + getCellValue: (row: SpotHeaderToken) => row.priceUsd ?? 0, + label: 'Price', + renderCell: ({ priceUsd }) => ( + + ), + }, + { + columnKey: 'marketCap', + getCellValue: (row) => row.marketCapUsd ?? 0, + label: 'Market Cap', + renderCell: ({ marketCapUsd, change24hPercent }) => ( + + + + + ), + }, + ] satisfies ColumnDef[], + [] + ); + + return ( + <$Popover + open={isOpen} + onOpenChange={setIsOpen} + noBlur + sideOffset={1} + className={className} + slotTrigger={ + <$TriggerContainer $isOpen={isOpen}> +
+ <$AssetIconWithStar> + {current?.tokenAddress && favoritedSet.has(current.tokenAddress) && ( + <$FavoriteStatus iconName={IconName.Star} /> + )} + <$AssetIcon + logoUrl={current?.logoUrl} + symbol={current?.symbol} + isLoading={isTokenLoading} + /> + + {isTokenLoading ? ( + + ) : ( +

{current?.symbol}

+ )} +
+

+ +

+ + } + triggerType={TriggerType.MarketDropdown} + > +
+ <$Toolbar> + <$SearchInput placeholder="Search markets" onTextChange={onSearchTextChange} /> + + <$ScrollArea> + {isSearchLoading ? ( + + ) : ( + <$Table + withOuterBorder + withInnerBorders + data={searchResults} + tableId="spot-markets-dropdown" + getRowKey={(row) => row.tokenAddress} + onRowAction={(_, row) => { + onSelect(row); + setIsOpen(false); + }} + label="Spot" + columns={columns} + paginationBehavior="paginate" + initialPageSize={20} + shouldResetOnTotalRowsChange + getIsRowPinned={(row) => favoritedSet.has(row.tokenAddress)} + defaultSortDescriptor={{ + column: 'volume24h', + direction: 'descending', + }} + slotEmpty={ + <> + +

Failed to find a matching token...

+ + } + /> + )} + +
+ + ); +}; + +const $Toolbar = styled(Toolbar)` + gap: 0.5rem; + border-bottom: solid var(--border-width) var(--color-border); + padding: 1rem 1rem 0.5rem; +`; + +const $SearchInput = styled(SearchInput)` + min-width: 12rem; + flex-grow: 1; +` as typeof SearchInput; + +const $TriggerContainer = styled.div<{ $isOpen: boolean }>` + position: relative; + + ${layoutMixins.spacedRow} + padding: 0 1.25rem; + + transition: width 0.1s; + gap: 1rem; +`; + +const $Popover = styled(Popover)` + ${popoverMixins.popover} + --popover-item-height: 2.75rem; + + --popover-backgroundColor: var(--color-layer-2); + display: flex; + flex-direction: column; + + height: calc( + 100vh - var(--page-header-height) - var(--market-info-row-height) - var( + --page-footer-height + ) - var(--restriction-warning-currentHeight) + ); + + width: var(--marketsDropdown-openWidth); + + max-width: 100vw; + + box-shadow: 0 0 0 1px var(--color-border); + border-radius: 0; + + &:focus-visible { + outline: none; + } +`; + +const $ScrollArea = styled.div` + overflow: scroll; + position: relative; + height: 100%; +`; + +const $Table = styled(Table)` + --tableCell-padding: 0.5rem 1rem; + --table-header-height: 2.25rem; + + thead { + --stickyArea-totalInsetTop: 0px; + --stickyArea-totalInsetBottom: 0px; + background-color: var(--color-layer-2); + + tr { + height: var(--stickyArea-topHeight); + } + } + + tfoot { + --stickyArea-totalInsetTop: 0px; + --stickyArea-totalInsetBottom: 3px; + background-color: var(--color-layer-2); + + tr { + height: var(--stickyArea-bottomHeight); + } + } + + tr { + height: var(--popover-item-height); + } +` as typeof Table; + +const $AssetIcon = styled(AssetIcon)` + --asset-icon-size: 1.5em; +`; + +const $FavoriteStatus = styled(Icon)` + --icon-size: 0.75em; + --icon-color: ${({ theme }) => theme.profileYellow}; + color: var(--icon-color); + fill: var(--icon-color); + z-index: 1; +`; + +const $AssetIconWithStar = styled.div` + ${layoutMixins.stack} + + ${$AssetIcon} { + margin: 0.2rem; + } +`; diff --git a/src/pages/spot/SpotTabs.tsx b/src/pages/spot/SpotTabs.tsx new file mode 100644 index 0000000000..391210f914 --- /dev/null +++ b/src/pages/spot/SpotTabs.tsx @@ -0,0 +1,151 @@ +import React, { ReactNode } from 'react'; + +import { Content, List, Root, Trigger } from '@radix-ui/react-tabs'; +import styled, { css } from 'styled-components'; + +import { layoutMixins } from '@/styles/layoutMixins'; + +export enum SpotTabVariant { + Buy = 'Buy', + Sell = 'Sell', + Default = 'Default', +} + +export type SpotTabItem = { + value: string; + label: ReactNode; + content?: ReactNode; + disabled?: boolean; + variant?: SpotTabVariant; +}; + +export type SpotTabsProps = { + items: SpotTabItem[]; + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; + className?: string; + sharedContent?: React.ReactNode; + disabled?: boolean; + hideTabs?: boolean; +}; + +export const SpotTabs = ({ + items, + value, + defaultValue, + onValueChange, + className, + sharedContent, + disabled, + hideTabs = false, +}: SpotTabsProps) => { + const fallbackValue = items.find((i) => !i.disabled)?.value; + + return ( + <$Root + value={value} + defaultValue={defaultValue ?? fallbackValue} + onValueChange={onValueChange} + className={className} + > + {!hideTabs && ( + <$List> + {items.map((item) => ( + <$Trigger + key={item.value} + value={item.value} + disabled={item.disabled ?? disabled} + $variant={item.variant ?? SpotTabVariant.Default} + > + {item.label} + + ))} + + )} + {sharedContent ?? + items.map((item) => ( + <$Content key={item.value} value={item.value}> + {item.content} + + ))} + + ); +}; + +const $Root = styled(Root)` + --tab-border-radius: 10px; + gap: 1rem; + + ${layoutMixins.contentContainer} + ${layoutMixins.scrollArea} +`; + +const $List = styled(List)` + display: flex; + flex-direction: row; + padding: 0.125rem; + background-color: var(--color-layer-1); + border-radius: var(--tab-border-radius); + gap: 0.125rem; +`; + +const spotTabVariants: Record> = { + [SpotTabVariant.Buy]: css` + --tab-textColor: var(--color-green); + --tab-backgroundColor: var(--color-green-faded); + `, + + [SpotTabVariant.Sell]: css` + --tab-textColor: var(--color-red); + --tab-backgroundColor: var(--color-red-faded); + `, + + [SpotTabVariant.Default]: css` + --tab-textColor: var(--color-text-2); + --tab-backgroundColor: var(--color-layer-4); + `, +}; + +const $Trigger = styled(Trigger)<{ + $variant: SpotTabVariant; +}>` + --tab-textColor: var(--color-text-0); + --tab-backgroundColor: var(--color-layer-1); + --tab-hover-backgroundColor: var(--color-layer-4); + --tab-hover-filter: brightness(var(--hover-filter-variant)); + + ${layoutMixins.textTruncate} + + &[data-state='active'] { + ${({ $variant }) => spotTabVariants[$variant]} + } + + &:disabled { + opacity: 0.5; + } + + &:hover:not(:disabled) { + filter: var(--tab-hover-filter); + } + + background-color: var(--tab-backgroundColor); + color: var(--tab-textColor); + cursor: pointer; + font-size: var(--fontSize-base); + font-weight: 500; + flex: 1; + height: 2.5rem; + border-radius: calc(var(--tab-border-radius) - 0.125rem); + padding: 0 1rem; + text-align: center; +`; + +const $Content = styled(Content)` + ${layoutMixins.flexColumn} + flex: 1; + + &[data-state='inactive'] { + display: none; + } +`; diff --git a/src/pages/spot/SpotTokenInfo.tsx b/src/pages/spot/SpotTokenInfo.tsx new file mode 100644 index 0000000000..541f53d5c6 --- /dev/null +++ b/src/pages/spot/SpotTokenInfo.tsx @@ -0,0 +1,110 @@ +import { type ReactNode } from 'react'; + +import { + ButtonAction, + ButtonShape, + ButtonSize, + ButtonStyle, + ButtonType, +} from '@/constants/buttons'; + +import { CopyButton } from '@/components/CopyButton'; +import { Icon, IconName } from '@/components/Icon'; +import { IconButton } from '@/components/IconButton'; +import { InfoGrid } from '@/components/InfoGrid'; +import { Output, OutputType } from '@/components/Output'; + +import { truncateAddress } from '@/lib/wallet'; + +export type TokenInfoLink = { + icon: IconName; + url: string; + title?: string; +}; + +export type TokenInfoItem = { + key: string; + label: string; + value: ReactNode; + iconName?: IconName; +}; + +type SpotTokenInfoProps = { + links: TokenInfoLink[]; + items: TokenInfoItem[]; + contractAddress: string; + createdAt?: number | string | Date; + className?: string; + isLoading?: boolean; +}; + +// TODO: spot localization + +export const SpotTokenInfo = ({ + links, + items, + contractAddress, + createdAt, + className, + isLoading = false, +}: SpotTokenInfoProps) => { + return ( +
+
+
Token Info
+
+ {(isLoading ? [null] : links).map((l) => ( + + ))} +
+
+ + ({ + key: item.key, + label: ( + + {item.iconName && } + {item.label} + + ), + value: item.value, + }))} + /> + +
+ + {truncateAddress(contractAddress, '')} + + {(isLoading || !!createdAt) && ( +
+ + +
+ )} +
+
+ ); +}; diff --git a/src/pages/spot/SpotTradeForm.tsx b/src/pages/spot/SpotTradeForm.tsx new file mode 100644 index 0000000000..688a1ccf70 --- /dev/null +++ b/src/pages/spot/SpotTradeForm.tsx @@ -0,0 +1,183 @@ +import { useCallback, useMemo, useRef } from 'react'; + +import { SpotBuyInputType, SpotSellInputType, SpotSide } from '@/bonsai/forms/spot'; +import { BonsaiCore } from '@/bonsai/ontology'; + +import { ButtonAction, ButtonState } from '@/constants/buttons'; +import { STRING_KEYS } from '@/constants/localization'; + +import { useSpotForm } from '@/hooks/useSpotForm'; +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { Button } from '@/components/Button'; +import { Icon, IconName } from '@/components/Icon'; +import { LoadingSpace } from '@/components/Loading/LoadingSpinner'; +import { ValidationAlertMessage } from '@/components/ValidationAlert'; + +import { useAppDispatch, useAppSelector } from '@/state/appTypes'; +import { setSpotQuickOptions } from '@/state/appUiConfigs'; +import { getSpotQuickOptions } from '@/state/appUiConfigsSelectors'; + +import { mapIfPresent } from '@/lib/do'; + +import { QuickButtonProps, QuickButtons } from './QuickButtons'; +import { SpotFormInput } from './SpotFormInput'; +import { SpotTabs, SpotTabVariant } from './SpotTabs'; + +export const SpotTradeForm = () => { + const stringGetter = useStringGetter(); + const dispatch = useAppDispatch(); + const form = useSpotForm(); + const tokenMetadata = useAppSelector(BonsaiCore.spot.tokenMetadata.data); + const quickOptionsState = useAppSelector(getSpotQuickOptions); + + const inputRef = useRef(null); + + const quickOptions = useMemo(() => { + if (form.state.side === SpotSide.BUY) { + return quickOptionsState[SpotSide.BUY][form.state.buyInputType]; + } + return quickOptionsState[SpotSide.SELL][form.state.sellInputType]; + }, [form.state.side, form.state.buyInputType, form.state.sellInputType, quickOptionsState]); + + const validationConfig = useMemo(() => { + if (form.state.side === SpotSide.BUY) { + return { min: 0, decimalScale: 2 }; + } + return form.state.sellInputType === SpotSellInputType.PERCENT + ? { min: 0, max: 100, decimalScale: 2 } + : { min: 0, decimalScale: 2 }; + }, [form.state.side, form.state.sellInputType]); + + const currencyIndicator = useMemo((): Pick< + QuickButtonProps, + 'prefix' | 'suffix' | 'slotRight' + > => { + const { side, buyInputType, sellInputType } = form.state; + + if (side === SpotSide.BUY && buyInputType === SpotBuyInputType.USD) { + return { prefix: '$' }; + } + + if (side === SpotSide.BUY && buyInputType === SpotBuyInputType.SOL) { + return { slotRight: }; + } + + if (side === SpotSide.SELL && sellInputType === SpotSellInputType.USD) { + return { prefix: '$' }; + } + + if (side === SpotSide.SELL && sellInputType === SpotSellInputType.PERCENT) { + return { suffix: '%' }; + } + + return {}; + }, [form.state]); + + const handleQuickOptionsChange = useCallback( + (newOptions: string[]) => { + dispatch( + setSpotQuickOptions({ + side: form.state.side, + inputType: + form.state.side === SpotSide.BUY ? form.state.buyInputType : form.state.sellInputType, + options: newOptions, + }) + ); + }, + [dispatch, form.state.buyInputType, form.state.sellInputType, form.state.side] + ); + + return ( + { + form.actions.setSide(v as SpotSide); + }} + sharedContent={ +
+ {!form.inputData.isReady ? ( + + ) : ( + <> + + form.actions.setSize(formattedValue) + } + balances={{ + sol: form.inputData.userSolBalance ?? 0, + token: form.inputData.userTokenBalance ?? 0, + usd: + mapIfPresent( + form.inputData.userSolBalance, + form.inputData.solPriceUsd, + (solBalance, solPrice) => solBalance * solPrice + ) ?? 0, + }} + inputType={ + form.state.side === SpotSide.BUY + ? form.state.buyInputType + : form.state.sellInputType + } + onInputTypeChange={form.handleInputTypeChange} + side={form.state.side} + tokenAmount={form.summary.amounts?.token ?? 0} + tokenSymbol={tokenMetadata?.symbol ?? ''} + /> + form.actions.setSize(val)} + onOptionsEdit={handleQuickOptionsChange} + currentValue={form.state.size} + disabled={form.isPending} + validation={validationConfig} + {...currencyIndicator} + /> + {form.primaryAlert != null && + (form.primaryAlert.resources.text?.stringKey != null || + form.primaryAlert.resources.text?.fallback != null) && ( + + )} + + + )} +
+ } + items={[ + { + label: stringGetter({ key: STRING_KEYS.BUY }), + value: SpotSide.BUY, + variant: SpotTabVariant.Buy, + }, + { + label: stringGetter({ key: STRING_KEYS.SELL }), + value: SpotSide.SELL, + variant: SpotTabVariant.Sell, + }, + ]} + /> + ); +}; diff --git a/src/pages/spot/SpotTradesTable.tsx b/src/pages/spot/SpotTradesTable.tsx new file mode 100644 index 0000000000..c46db907a4 --- /dev/null +++ b/src/pages/spot/SpotTradesTable.tsx @@ -0,0 +1,200 @@ +import { useMemo } from 'react'; + +import { OrderSide } from '@dydxprotocol/v4-client-js'; +import { ColumnSize } from '@react-types/table'; +import styled from 'styled-components'; + +import { ButtonStyle, ButtonType } from '@/constants/buttons'; + +import { defaultTableMixins } from '@/styles/tableMixins'; + +import { AssetIcon } from '@/components/AssetIcon'; +import { Icon, IconName } from '@/components/Icon'; +import { IconButton } from '@/components/IconButton'; +import { OrderSideTag } from '@/components/OrderSideTag'; +import { Output, OutputType } from '@/components/Output'; +import { ColumnDef, Table } from '@/components/Table'; +import { TableCell } from '@/components/Table/TableCell'; +import { TagSize } from '@/components/Tag'; + +export interface SpotTradeItem { + id: string; + side: 'buy' | 'sell'; + tokenAmount: number; + usdValue: number; + txHash: string; + createdAt: string; + tokenSymbol?: string; + tokenImage?: string; + marketCapUsd?: number; +} + +export enum SpotTradesTableColumnKey { + Time = 'Time', + Token = 'Token', + Type = 'Type', + MarketCap = 'MarketCap', + Amount = 'Amount', + UsdAmount = 'UsdAmount', + ViewTxn = 'ViewTxn', +} + +// TODO: spot localization + +const getColumnDef = ({ + key, + width, +}: { + key: SpotTradesTableColumnKey; + width?: ColumnSize; +}): ColumnDef => ({ + width, + ...( + { + [SpotTradesTableColumnKey.Time]: { + columnKey: 'time', + label: 'Time', + getCellValue: (row) => row.createdAt, + renderCell: ({ createdAt }) => ( + + + + ), + }, + [SpotTradesTableColumnKey.Token]: { + columnKey: 'token', + label: 'Token', + getCellValue: (row) => row.tokenSymbol, + renderCell: ({ tokenSymbol, tokenImage }) => ( + + } + > + {tokenSymbol ?? 'Unknown'} + + ), + }, + [SpotTradesTableColumnKey.Type]: { + columnKey: 'side', + label: 'Type', + getCellValue: (row) => row.side, + renderCell: ({ side }) => ( + + + + ), + }, + [SpotTradesTableColumnKey.MarketCap]: { + columnKey: 'marketCapUsd', + label: 'Market Cap', + getCellValue: (row) => row.marketCapUsd ?? 0, + renderCell: ({ marketCapUsd }) => ( + + + + ), + }, + [SpotTradesTableColumnKey.Amount]: { + columnKey: 'tokenAmount', + label: 'Amount', + getCellValue: (row) => row.tokenAmount, + renderCell: ({ tokenAmount, tokenSymbol }) => ( + + + + ), + }, + [SpotTradesTableColumnKey.UsdAmount]: { + columnKey: 'usdValue', + label: 'USD Amount', + getCellValue: (row) => row.usdValue, + renderCell: ({ usdValue }) => ( + + + + ), + }, + [SpotTradesTableColumnKey.ViewTxn]: { + columnKey: 'txHash', + label: 'View Txn', + allowsSorting: false, + isActionable: true, + renderCell: ({ txHash }) => ( + + + + ), + }, + } satisfies Record> + )[key], +}); + +export type SpotTradesTableProps = { + data: SpotTradeItem[]; + columnKeys?: SpotTradesTableColumnKey[]; + columnWidths?: Partial>; +}; + +export const SpotTradesTable = ({ + data, + columnKeys = [ + SpotTradesTableColumnKey.Time, + SpotTradesTableColumnKey.Token, + SpotTradesTableColumnKey.Type, + SpotTradesTableColumnKey.MarketCap, + SpotTradesTableColumnKey.Amount, + SpotTradesTableColumnKey.UsdAmount, + SpotTradesTableColumnKey.ViewTxn, + ], + columnWidths, +}: SpotTradesTableProps) => { + const columns = useMemo( + () => columnKeys.map((key) => getColumnDef({ key, width: columnWidths?.[key] })), + [columnKeys, columnWidths] + ); + + return ( + <$Table + defaultSortDescriptor={{ + column: 'time', + direction: 'descending', + }} + tableId="spot-trades" + data={data} + getRowKey={(row) => row.id} + columns={columns} + initialPageSize={20} + paginationBehavior="paginate" + withInnerBorders + withScrollSnapColumns + withScrollSnapRows + slotEmpty={ + <> + +

No trades yet...

+ + } + /> + ); +}; + +const $Table = styled(Table)` + ${defaultTableMixins} +` as typeof Table; diff --git a/src/pages/spot/StackedIcon.tsx b/src/pages/spot/StackedIcon.tsx new file mode 100644 index 0000000000..ac8596d9be --- /dev/null +++ b/src/pages/spot/StackedIcon.tsx @@ -0,0 +1,71 @@ +import styled, { css } from 'styled-components'; + +import { Icon, IconName } from '@/components/Icon'; + +export type SecondaryIconPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'; + +export type StackedIconProps = { + primaryIcon: IconName; + secondaryIcon: IconName; + primarySize?: string; + secondarySize?: string; + secondaryPosition?: SecondaryIconPosition; + secondaryOffset?: number; +}; + +export const StackedIcon = ({ + primaryIcon, + secondaryIcon, + primarySize = '1.25rem', + secondarySize = '0.625rem', + secondaryPosition = 'bottom-right', + secondaryOffset = 15, +}: StackedIconProps) => { + return ( +
+ + <$SecondaryIcon + iconName={secondaryIcon} + size={secondarySize} + $offset={secondaryOffset} + $position={secondaryPosition} + /> +
+ ); +}; + +const $SecondaryIcon = styled(Icon)<{ + $offset: number; + $position: SecondaryIconPosition; +}>` + --offset: ${({ $offset }) => $offset}%; + + position: absolute; + outline: 1.5px solid var(--color-layer-4); + border-radius: 9999px; + overflow: hidden; + + ${({ $position }) => + ({ + 'bottom-right': css` + bottom: 0; + right: 0; + transform: translate(var(--offset), var(--offset)); + `, + 'top-right': css` + top: 0; + right: 0; + transform: translate(var(--offset), calc(-1 * var(--offset))); + `, + 'top-left': css` + top: 0; + left: 0; + transform: translate(calc(-1 * var(--offset)), calc(-1 * var(--offset))); + `, + 'bottom-left': css` + bottom: 0; + left: 0; + transform: translate(calc(-1 * var(--offset)), var(--offset)); + `, + })[$position]} +`; diff --git a/src/pages/spot/types.ts b/src/pages/spot/types.ts new file mode 100644 index 0000000000..49312da73e --- /dev/null +++ b/src/pages/spot/types.ts @@ -0,0 +1,23 @@ +export interface SpotHeaderToken { + tokenAddress: string; + name: string; + symbol: string; + logoUrl?: string | null; + volume24hUsd?: number; + priceUsd?: number; + marketCapUsd?: number; + change24hPercent?: number; + fdvUsd?: number; + liquidityUsd?: number; + circulatingSupply?: number; + totalSupply?: number; + buys24hUsd?: number; + sells24hUsd?: number; + holders?: number; + top10HoldersPercent?: number; + devHoldingPercent?: number; + snipersPercent?: number; + bundlersPercent?: number; + insidersPercent?: number; + createdAt?: Date; +} diff --git a/src/state/_store.ts b/src/state/_store.ts index f308f1c8db..e7da305cf8 100644 --- a/src/state/_store.ts +++ b/src/state/_store.ts @@ -27,6 +27,8 @@ import { customCreateMigrate } from './migrations'; import { notificationsSlice } from './notifications'; import { perpetualsSlice } from './perpetuals'; import { rawSlice } from './raw'; +import { spotSlice } from './spot'; +import { spotFormSlice } from './spotForm'; import { swapsSlice } from './swaps'; import { tradeFormSlice } from './tradeForm'; import { getClosePositionFormSummary, getTradeFormSummary } from './tradeFormSelectors'; @@ -47,6 +49,8 @@ const reducers = { triggersForm: triggersFormSlice.reducer, tradeForm: tradeFormSlice.reducer, closePositionForm: closePositionFormSlice.reducer, + spot: spotSlice.reducer, + spotForm: spotFormSlice.reducer, layout: layoutSlice.reducer, localization: localizationSlice.reducer, localOrders: localOrdersSlice.reducer, diff --git a/src/state/accountInfoSelectors.ts b/src/state/accountInfoSelectors.ts index 6d509f3020..1ce457c928 100644 --- a/src/state/accountInfoSelectors.ts +++ b/src/state/accountInfoSelectors.ts @@ -9,3 +9,6 @@ export const getSubaccountId = (state: RootState) => state.wallet.localWallet?.s export const getUserWalletAddress = (state: RootState) => state.wallet.localWallet?.address; export const getUserSourceWalletAddress = (state: RootState) => state.wallet.sourceAccount.address; + +export const getUserSolanaWalletAddress = (state: RootState) => + state.wallet.localWallet?.solanaAddress; diff --git a/src/state/app.ts b/src/state/app.ts index 0c6a314e8d..3a9b4d5e7b 100644 --- a/src/state/app.ts +++ b/src/state/app.ts @@ -10,6 +10,7 @@ export interface AppState { pageLoaded: boolean; initializationError?: string; selectedNetwork: DydxNetwork; + currentPath: string; } const initialState: AppState = { @@ -20,6 +21,7 @@ const initialState: AppState = { defaultValue: DEFAULT_APP_ENVIRONMENT, validateFn: validateAgainstAvailableEnvironments, }), + currentPath: '/', }; export const appSlice = createSlice({ @@ -37,8 +39,15 @@ export const appSlice = createSlice({ setInitializationError: (state: AppState, action: PayloadAction) => { state.initializationError = action.payload; }, + setCurrentPath: (state: AppState, action: PayloadAction) => { + state.currentPath = action.payload; + }, }, }); -export const { initializeLocalization, setSelectedNetwork, setInitializationError } = - appSlice.actions; +export const { + initializeLocalization, + setSelectedNetwork, + setInitializationError, + setCurrentPath, +} = appSlice.actions; diff --git a/src/state/appSelectors.ts b/src/state/appSelectors.ts index 34b8229552..064ace822d 100644 --- a/src/state/appSelectors.ts +++ b/src/state/appSelectors.ts @@ -4,6 +4,7 @@ import type { RootState } from './_store'; export const getSelectedNetwork = (state: RootState) => state.app.selectedNetwork; export const getInitializationError = (state: RootState) => state.app.initializationError; +export const getCurrentPath = (state: RootState) => state.app.currentPath; export const getSelectedDydxChainId = (state: RootState) => ENVIRONMENT_CONFIG_MAP[state.app.selectedNetwork].dydxChainId as DydxChainId; @@ -11,5 +12,8 @@ export const getSelectedDydxChainId = (state: RootState) => export const getMetadataEndpoint = (state: RootState) => ENVIRONMENT_CONFIG_MAP[getSelectedNetwork(state)].endpoints.metadataService; +export const getSpotApiEndpoint = (state: RootState) => + ENVIRONMENT_CONFIG_MAP[getSelectedNetwork(state)].endpoints.spotApi; + export const getGeoCheckEnabled = (state: RootState) => ENVIRONMENT_CONFIG_MAP[getSelectedNetwork(state)].featureFlags.checkForGeo; diff --git a/src/state/appUiConfigs.ts b/src/state/appUiConfigs.ts index d04f3e70e7..14d0d789dc 100644 --- a/src/state/appUiConfigs.ts +++ b/src/state/appUiConfigs.ts @@ -1,3 +1,4 @@ +import { SpotBuyInputType, SpotSellInputType, SpotSide } from '@/bonsai/forms/spot'; import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; import { AnalyticsEvents } from '@/constants/analytics'; @@ -6,6 +7,28 @@ import { DisplayUnit } from '@/constants/trade'; import { track } from '@/lib/analytics/analytics'; +export type SpotQuickOptions = { + [SpotSide.SELL]: { + [SpotSellInputType.PERCENT]: string[]; + [SpotSellInputType.USD]: string[]; + }; + [SpotSide.BUY]: { + [SpotBuyInputType.USD]: string[]; + [SpotBuyInputType.SOL]: string[]; + }; +}; + +export const DEFAULT_SPOT_QUICK_OPTIONS: SpotQuickOptions = { + [SpotSide.SELL]: { + [SpotSellInputType.PERCENT]: ['10', '25', '50', '100'], + [SpotSellInputType.USD]: ['50', '100', '250', '500'], + }, + [SpotSide.BUY]: { + [SpotBuyInputType.USD]: ['50', '100', '250', '500'], + [SpotBuyInputType.SOL]: ['0.1', '0.25', '0.5', '1'], + }, +}; + export enum AppTheme { Classic = 'Classic', Dark = 'Dark', @@ -38,6 +61,8 @@ export interface AppUIConfigsState { displayUnit: DisplayUnit; shouldHideLaunchableMarkets: boolean; favoritedMarkets: string[]; + spotFavorites: string[]; + spotQuickOptions: SpotQuickOptions; horizontalPanelHeightPx: number; tablePageSizes: { [tableKey: string]: number }; simpleUI: { @@ -54,6 +79,8 @@ export const initialState: AppUIConfigsState = { displayUnit: DisplayUnit.Asset, shouldHideLaunchableMarkets: false, favoritedMarkets: [], + spotFavorites: [], + spotQuickOptions: DEFAULT_SPOT_QUICK_OPTIONS, horizontalPanelHeightPx: 288, tablePageSizes: {}, simpleUI: { @@ -120,6 +147,20 @@ export const appUiConfigsSlice = createSlice({ const newFavoritedMarkets = currentFavoritedMarkets.filter((id) => id !== marketId); state.favoritedMarkets = newFavoritedMarkets; }, + favoriteSpotToken: ( + state: AppUIConfigsState, + { payload: tokenAddress }: PayloadAction + ) => { + const currentFavoritedTokens = state.spotFavorites; + const newFavoritedTokens = [...currentFavoritedTokens, tokenAddress]; + state.spotFavorites = newFavoritedTokens; + }, + unfavoriteSpotToken: ( + state: AppUIConfigsState, + { payload: tokenAddress }: PayloadAction + ) => { + state.spotFavorites = state.spotFavorites.filter((id) => id !== tokenAddress); + }, setTablePageSize: ( state: AppUIConfigsState, { payload: { pageSize, tableId } }: PayloadAction<{ tableId: string; pageSize: number }> @@ -138,6 +179,23 @@ export const appUiConfigsSlice = createSlice({ ) => { state.simpleUI.sortPositionsBy = payload; }, + setSpotQuickOptions: ( + state: AppUIConfigsState, + { + payload, + }: PayloadAction<{ + side: SpotSide; + inputType: SpotBuyInputType | SpotSellInputType; + options: string[]; + }> + ) => { + const { side, inputType, options } = payload; + if (side === SpotSide.BUY) { + state.spotQuickOptions[SpotSide.BUY][inputType as SpotBuyInputType] = options; + } else { + state.spotQuickOptions[SpotSide.SELL][inputType as SpotSellInputType] = options; + } + }, }, }); @@ -154,4 +212,7 @@ export const { setTablePageSize, setSimpleUISortMarketsBy, setSimpleUISortPositionsBy, + favoriteSpotToken, + unfavoriteSpotToken, + setSpotQuickOptions, } = appUiConfigsSlice.actions; diff --git a/src/state/appUiConfigsSelectors.ts b/src/state/appUiConfigsSelectors.ts index b0fc68e9bf..3f233c4094 100644 --- a/src/state/appUiConfigsSelectors.ts +++ b/src/state/appUiConfigsSelectors.ts @@ -52,6 +52,7 @@ export const getShouldHideLaunchableMarkets = (state: RootState) => state.appUiConfigs.shouldHideLaunchableMarkets; export const getFavoritedMarkets = (state: RootState) => state.appUiConfigs.favoritedMarkets; +export const getSpotFavorites = (state: RootState) => state.appUiConfigs.spotFavorites; export const getHorizontalPanelHeightPx = (state: RootState) => state.appUiConfigs.horizontalPanelHeightPx; @@ -61,6 +62,11 @@ export const getIsMarketFavorited = createAppSelector( (favoritedMarkets, marketId) => favoritedMarkets.includes(marketId) ); +export const getIsSpotTokenFavorited = createAppSelector( + [getSpotFavorites, (_, tokenAddress: string) => tokenAddress], + (favorited, tokenAddress) => favorited.includes(tokenAddress) +); + export const getSavedTablePageSize = createAppSelector( [(state) => state.appUiConfigs.tablePageSizes, (_s, id: string) => id], (pageSizes, id) => pageSizes[id] @@ -72,3 +78,6 @@ export const getSimpleUISortMarketsBy = (state: RootState) => export const getSimpleUISortPositionsBy = (state: RootState) => state.appUiConfigs.simpleUI.sortPositionsBy; + +// Spot +export const getSpotQuickOptions = (state: RootState) => state.appUiConfigs.spotQuickOptions; diff --git a/src/state/raw.ts b/src/state/raw.ts index 39d53b0d8a..22acd5da33 100644 --- a/src/state/raw.ts +++ b/src/state/raw.ts @@ -33,7 +33,13 @@ import { IndexerSparklineResponseObject, } from '@/types/indexer/indexerManual'; +import { + SpotApiPortfolioTradesResponse, + SpotApiTokenMetadataResponse, + SpotApiTokenPriceResponse, +} from '@/clients/spotApi'; import { calc } from '@/lib/do'; +import { SpotApiWsWalletPositionsUpdate } from '@/lib/streaming/walletPositionsStreaming'; import { autoBatchAllReducers } from './autoBatchHelpers'; @@ -104,6 +110,13 @@ export interface RawDataState { data: Loadable; price: Loadable; }; + spot: { + solPrice: Loadable; + tokenPrice: Loadable; + tokenMetadata: Loadable; + walletPositions: Loadable; + portfolioTrades: Loadable; + }; } const initialState: RawDataState = { @@ -143,6 +156,13 @@ const initialState: RawDataState = { data: loadableIdle(), price: loadableIdle(), }, + spot: { + solPrice: loadableIdle(), + tokenMetadata: loadableIdle(), + tokenPrice: loadableIdle(), + walletPositions: loadableIdle(), + portfolioTrades: loadableIdle(), + }, }; export const rawSlice = createSlice({ @@ -253,6 +273,36 @@ export const rawSlice = createSlice({ ) => { state.markets.selectedMarketLeverages = action.payload; }, + setSpotSolPrice: ( + state, + action: PayloadAction> + ) => { + state.spot.solPrice = action.payload; + }, + setSpotTokenPrice: ( + state, + action: PayloadAction> + ) => { + state.spot.tokenPrice = action.payload; + }, + setSpotTokenMetadata: ( + state, + action: PayloadAction> + ) => { + state.spot.tokenMetadata = action.payload; + }, + setSpotWalletPositions: ( + state, + action: PayloadAction> + ) => { + state.spot.walletPositions = action.payload; + }, + setSpotPortfolioTrades: ( + state, + action: PayloadAction> + ) => { + state.spot.portfolioTrades = action.payload; + }, }), // orderbook is throttled separately for fine-grained control setOrderbookRaw: ( @@ -340,4 +390,9 @@ export const { setRewardsTokenPrice, setSelectedMarketLeverage, setSelectedMarketLeverages, + setSpotSolPrice, + setSpotTokenPrice, + setSpotTokenMetadata, + setSpotWalletPositions, + setSpotPortfolioTrades, } = rawSlice.actions; diff --git a/src/state/spot.ts b/src/state/spot.ts new file mode 100644 index 0000000000..63d1ed54b7 --- /dev/null +++ b/src/state/spot.ts @@ -0,0 +1,25 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +import type { RootState } from './_store'; + +export type SpotState = { + currentSpotToken: string | undefined; +}; + +const initialState: SpotState = { + currentSpotToken: undefined, +}; + +export const spotSlice = createSlice({ + name: 'Spot', + initialState, + reducers: { + setCurrentSpotToken: (state: SpotState, action: PayloadAction) => { + state.currentSpotToken = action.payload; + }, + }, +}); + +export const { setCurrentSpotToken } = spotSlice.actions; + +export const getCurrentSpotToken = (state: RootState) => state.spot.currentSpotToken; diff --git a/src/state/spotForm.ts b/src/state/spotForm.ts new file mode 100644 index 0000000000..b407abfbbc --- /dev/null +++ b/src/state/spotForm.ts @@ -0,0 +1,13 @@ +import { convertVanillaReducerActionsToReduxToolkitReducers } from '@/bonsai/lib/forms'; +import { BonsaiForms } from '@/bonsai/ontology'; +import { createSlice } from '@reduxjs/toolkit'; + +const spotFormReducer = BonsaiForms.SpotFormFns.reducer; + +export const spotFormSlice = createSlice({ + name: 'spotForm', + initialState: spotFormReducer.initialState, + reducers: convertVanillaReducerActionsToReduxToolkitReducers(spotFormReducer), +}); + +export const spotFormActions = spotFormSlice.actions; diff --git a/src/state/spotFormSelectors.ts b/src/state/spotFormSelectors.ts new file mode 100644 index 0000000000..e06b29f242 --- /dev/null +++ b/src/state/spotFormSelectors.ts @@ -0,0 +1,87 @@ +import { SpotFormInputData } from '@/bonsai/forms/spot'; +import { BonsaiCore, BonsaiForms } from '@/bonsai/ontology'; +import { LAMPORTS_PER_SOL } from '@solana/web3.js'; + +import { SpotApiTradeRoute } from '@/clients/spotApi'; +import { isPresent } from '@/lib/typeUtils'; + +import { type RootState } from './_store'; +import { getUserSolanaWalletAddress } from './accountInfoSelectors'; +import { createAppSelector } from './appTypes'; +import { getCurrentSpotToken } from './spot'; + +export const getSpotFormRawState = (state: RootState) => state.spotForm; + +export const getSpotFormInputData = createAppSelector( + [ + BonsaiCore.spot.solPrice.data, + BonsaiCore.spot.tokenMetadata.data, + BonsaiCore.spot.tokenPrice.data, + BonsaiCore.spot.walletPositions.data, + BonsaiCore.spot.solPrice.loading, + BonsaiCore.spot.tokenMetadata.loading, + BonsaiCore.spot.tokenPrice.loading, + BonsaiCore.spot.walletPositions.loading, + getCurrentSpotToken, + getUserSolanaWalletAddress, + ], + ( + solPriceUsd, + tokenMetadata, + tokenPriceUsd, + walletPositions, + solPriceStatus, + tokenMetadataStatus, + tokenPriceStatus, + walletPositionsStatus, + tokenMint, + solanaAddress + ): SpotFormInputData => { + const userSolBalance = isPresent(walletPositions?.solBalance) + ? walletPositions.solBalance / LAMPORTS_PER_SOL + : undefined; + + const userTokenBalance = walletPositions?.tokenBalances.find( + (position) => position.mint === tokenMint + )?.amount; + + const isAsyncDataReady = [ + solPriceStatus, + tokenMetadataStatus, + tokenPriceStatus, + walletPositionsStatus, + ].every((status) => status === 'success'); + + const isRestReady = isPresent(tokenMint) && isPresent(solanaAddress); + + return { + tokenPriceUsd, + solPriceUsd, + userSolBalance, + userTokenBalance, + tokenMint: tokenMetadata?.tokenMint, + decimals: tokenMetadata?.decimals, + pairAddress: tokenMetadata?.pairAddress, + tradeRoute: tokenMetadata?.tradeRoute as SpotApiTradeRoute | undefined, + solanaAddress, + isReady: isRestReady && isAsyncDataReady, + isAsyncDataReady, + isRestReady, + }; + } +); + +export const getSpotFormSummary = createAppSelector( + [getSpotFormRawState, getSpotFormInputData], + (formState, inputData) => { + const summary = BonsaiForms.SpotFormFns.calculateSummary(formState, inputData); + const errors = BonsaiForms.SpotFormFns.getErrors(formState, inputData, summary); + + return { + inputData, + summary, + errors, + state: formState, + }; + } +); diff --git a/src/state/transfers.ts b/src/state/transfers.ts index c701255cd7..3c55d2b426 100644 --- a/src/state/transfers.ts +++ b/src/state/transfers.ts @@ -40,7 +40,18 @@ export type Withdraw = { txHash: string; }; -export type Transfer = Deposit | Withdraw; +export type SpotWithdraw = { + id: string; + type: 'spot-withdraw'; + amount: string; + destinationAddress: string; + txSignature?: string; + status: 'pending' | 'success' | 'error'; + error?: string; + updatedAt?: number; +}; + +export type Transfer = Deposit | Withdraw | SpotWithdraw; export function isDeposit(transfer: Transfer): transfer is Deposit { return transfer.type === 'deposit'; @@ -50,6 +61,10 @@ export function isWithdraw(transfer: Transfer): transfer is Withdraw { return transfer.type === 'withdraw'; } +export function isSpotWithdraw(transfer: Transfer): transfer is SpotWithdraw { + return transfer.type === 'spot-withdraw'; +} + export interface TransferState { transfersByDydxAddress: { [account: DydxAddress]: Transfer[] }; } @@ -156,11 +171,51 @@ export const transfersSlice = createSlice({ transfer.updatedAt = Date.now(); } + return transfer; + }); + }, + addSpotWithdraw: ( + state, + action: PayloadAction<{ dydxAddress: DydxAddress; withdraw: SpotWithdraw }> + ) => { + const { dydxAddress, withdraw } = action.payload; + if (!state.transfersByDydxAddress[dydxAddress]) { + state.transfersByDydxAddress[dydxAddress] = []; + } + + const newWithdraw = { ...withdraw, updatedAt: Date.now() }; + + state.transfersByDydxAddress[dydxAddress].push(newWithdraw); + }, + updateSpotWithdraw: ( + state, + action: PayloadAction<{ + dydxAddress: DydxAddress; + withdrawId: string; + updates: Partial; + }> + ) => { + const { dydxAddress, withdrawId, updates } = action.payload; + const accountTransfers = state.transfersByDydxAddress[dydxAddress]; + if (!accountTransfers?.length) return; + + state.transfersByDydxAddress[dydxAddress] = accountTransfers.map((transfer) => { + if (isSpotWithdraw(transfer) && transfer.id === withdrawId) { + return { ...transfer, updatedAt: Date.now(), ...updates }; + } + return transfer; }); }, }, }); -export const { addDeposit, addWithdraw, onWithdrawBroadcast, updateDeposit, updateWithdraw } = - transfersSlice.actions; +export const { + addDeposit, + addWithdraw, + onWithdrawBroadcast, + updateDeposit, + updateWithdraw, + addSpotWithdraw, + updateSpotWithdraw, +} = transfersSlice.actions; diff --git a/src/state/transfersSelectors.ts b/src/state/transfersSelectors.ts index fe9990da79..9c88901345 100644 --- a/src/state/transfersSelectors.ts +++ b/src/state/transfersSelectors.ts @@ -6,7 +6,15 @@ import { DydxAddress } from '@/constants/wallets'; import type { RootState } from './_store'; import { createAppSelector } from './appTypes'; -import { Deposit, isDeposit, isWithdraw, Transfer, Withdraw } from './transfers'; +import { + Deposit, + isDeposit, + isSpotWithdraw, + isWithdraw, + SpotWithdraw, + Transfer, + Withdraw, +} from './transfers'; export const getTransfersByAddress = (state: RootState) => state.transfers.transfersByDydxAddress; @@ -82,6 +90,15 @@ export const selectWithdraw = createAppSelector( } ); +export const selectSpotWithdraw = createAppSelector( + [selectAllTransfers, (s, id: string) => id], + (allTransfers, id) => { + return allTransfers.find( + (transfer): transfer is SpotWithdraw => isSpotWithdraw(transfer) && transfer.id === id + ); + } +); + export const selectHasNonExpiredPendingWithdraws = createAppSelector( [selectParentSubaccountInfo, getTransfersByAddress], (parentSubaccountInfo, transfersByAddress) => { diff --git a/src/state/wallet.ts b/src/state/wallet.ts index a0a97e7bbc..b539c3da32 100644 --- a/src/state/wallet.ts +++ b/src/state/wallet.ts @@ -16,6 +16,7 @@ export interface WalletState { sourceAccount: SourceAccount; localWallet?: { address?: string; + solanaAddress?: string; subaccountNumber?: number; }; turnkeyEmailOnboardingData?: TurnkeyEmailOnboardingData; @@ -73,7 +74,9 @@ export const walletSlice = createSlice({ }, setLocalWallet: ( state, - { payload }: PayloadAction<{ address?: string; subaccountNumber?: number }> + { + payload, + }: PayloadAction<{ address?: string; solanaAddress?: string; subaccountNumber?: number }> ) => { state.localWallet = payload; }, diff --git a/src/styles/constants.css b/src/styles/constants.css index 2bd23f1df6..1d8b928ba8 100644 --- a/src/styles/constants.css +++ b/src/styles/constants.css @@ -14,6 +14,7 @@ /* Sidebar constants */ --sidebar-width: 20.25rem; + --spot-sidebar-width: 25rem; --collapsed-sidebar-width: 3.5rem; /* Content constants */ diff --git a/src/styles/globalStyle.ts b/src/styles/globalStyle.ts index e340db1ebd..2d270cf541 100644 --- a/src/styles/globalStyle.ts +++ b/src/styles/globalStyle.ts @@ -7,6 +7,8 @@ export const GlobalStyle = createGlobalStyle` --color-green: ${({ theme }) => theme.green}; --color-red: ${({ theme }) => theme.red}; + --color-red-faded: ${({ theme }) => theme.redFaded}; + --color-green-faded: ${({ theme }) => theme.greenFaded}; --color-white-faded: ${({ theme }) => theme.whiteFaded}; --color-layer-0: ${({ theme }) => theme.layer0}; @@ -59,6 +61,8 @@ export const GlobalStyle = createGlobalStyle` --color-risk-medium: ${({ theme }) => theme.riskMedium}; --color-risk-high: ${({ theme }) => theme.riskHigh}; + --color-input-background: ${({ theme }) => theme.inputBackground}; + --hover-filter-base: ${({ theme }) => theme.hoverFilterBase}; --hover-filter-variant: ${({ theme }) => theme.hoverFilterVariant}; --active-filter: ${({ theme }) => theme.activeFilter}; diff --git a/src/styles/themes.ts b/src/styles/themes.ts index 67d65f6131..568961b883 100644 --- a/src/styles/themes.ts +++ b/src/styles/themes.ts @@ -11,6 +11,8 @@ const ClassicThemeBase: () => ThemeColorBase = () => ({ green: ColorToken.Green3, red: ColorToken.Red2, + redFaded: generateFadedColorVariant(ColorToken.Red2, OpacityToken.Opacity16), + greenFaded: generateFadedColorVariant(ColorToken.Green3, OpacityToken.Opacity16), whiteFaded: generateFadedColorVariant(ColorToken.White, OpacityToken.Opacity16), layer0: ColorToken.GrayBlue7, @@ -84,6 +86,8 @@ const DarkThemeBase: () => ThemeColorBase = () => ({ green: ColorToken.Green1, red: ColorToken.Red0, + redFaded: generateFadedColorVariant(ColorToken.Red0, OpacityToken.Opacity16), + greenFaded: generateFadedColorVariant(ColorToken.Green1, OpacityToken.Opacity16), whiteFaded: generateFadedColorVariant(ColorToken.White, OpacityToken.Opacity16), layer0: ColorToken.Black, @@ -157,6 +161,8 @@ const LightThemeBase: () => ThemeColorBase = () => ({ green: ColorToken.Green5, red: ColorToken.Red1, + redFaded: generateFadedColorVariant(ColorToken.Red1, OpacityToken.Opacity16), + greenFaded: generateFadedColorVariant(ColorToken.Green5, OpacityToken.Opacity16), whiteFaded: generateFadedColorVariant(ColorToken.White, OpacityToken.Opacity16), layer0: ColorToken.LightGray7, diff --git a/src/views/Lists/Alerts/SkipTransferNotificationRow.tsx b/src/views/Lists/Alerts/SkipTransferNotificationRow.tsx index ced719285f..74a4886fcb 100644 --- a/src/views/Lists/Alerts/SkipTransferNotificationRow.tsx +++ b/src/views/Lists/Alerts/SkipTransferNotificationRow.tsx @@ -10,7 +10,7 @@ import { useStringGetter } from '@/hooks/useStringGetter'; import { Icon, IconName } from '@/components/Icon'; import { Output, OutputType, ShowSign } from '@/components/Output'; -import { Transfer } from '@/state/transfers'; +import { isSpotWithdraw, Transfer } from '@/state/transfers'; import { MustBigNumber } from '@/lib/numbers'; import { truncateAddress } from '@/lib/wallet'; @@ -29,7 +29,14 @@ export const SkipTransferNotificationRow = ({ }) => { const stringGetter = useStringGetter(); const { dydxAddress } = useAccounts(); - const { type, status, estimatedAmountUsd, finalAmountUsd, updatedAt } = transfer; + const { type, status } = transfer; + + // Skip spot withdrawals for now + if (isSpotWithdraw(transfer)) { + return null; + } + + const { estimatedAmountUsd, finalAmountUsd, updatedAt } = transfer; const transferAmountBN = MustBigNumber(finalAmountUsd ?? estimatedAmountUsd); const isReceiving = type === 'deposit'; const multiplier = isReceiving ? 1 : -1; diff --git a/src/views/charts/TradingView/SpotTvChart.tsx b/src/views/charts/TradingView/SpotTvChart.tsx index fc576d1297..6c2cd8810f 100644 --- a/src/views/charts/TradingView/SpotTvChart.tsx +++ b/src/views/charts/TradingView/SpotTvChart.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import type { TvWidget } from '@/constants/tvchart'; +import { useSpotChartMarketAndResolution } from '@/hooks/tradingView/useSpotChartMarketAndResolution'; import { useSpotTradingView } from '@/hooks/tradingView/useSpotTradingView'; import { useTradingViewTheme } from '@/hooks/tradingView/useTradingViewTheme'; import { useSimpleUiEnabled } from '@/hooks/useSimpleUiEnabled'; @@ -9,16 +10,21 @@ import { useSimpleUiEnabled } from '@/hooks/useSimpleUiEnabled'; import { BaseTvChart } from './BaseTvChart'; export interface SpotTvChartProps { - symbol: string; + tokenMint: string; } -export const SpotTvChart = ({ symbol }: SpotTvChartProps) => { +export const SpotTvChart = ({ tokenMint }: SpotTvChartProps) => { const [tvWidget, setTvWidget] = useState(); const isSimpleUi = useSimpleUiEnabled(); useSpotTradingView({ setTvWidget, - symbol, + tokenMint, + }); + + useSpotChartMarketAndResolution({ + tokenMint, + tvWidget, }); useTradingViewTheme({ tvWidget }); diff --git a/src/views/dialogs/TransferDialogs/TransferStatusDialog.tsx b/src/views/dialogs/TransferDialogs/TransferStatusDialog.tsx index a01d3bfd97..fcdef33991 100644 --- a/src/views/dialogs/TransferDialogs/TransferStatusDialog.tsx +++ b/src/views/dialogs/TransferDialogs/TransferStatusDialog.tsx @@ -8,7 +8,7 @@ import { useStringGetter } from '@/hooks/useStringGetter'; import { Dialog, DialogPlacement } from '@/components/Dialog'; -import { isDeposit, isWithdraw } from '@/state/transfers'; +import { isDeposit, isSpotWithdraw, isWithdraw } from '@/state/transfers'; import { selectTransfer } from '@/state/transfersSelectors'; import { DepositStatus } from './DepositDialog2/DepositForm/DepositStatus'; @@ -36,7 +36,9 @@ export const TransferStatusDialog = ({ chainId={transfer.chainId} /> ) : transfer && isWithdraw(transfer) ? ( - setIsOpen(false)} id={transferId} /> + setIsOpen(false)} id={transferId} type="perps" /> + ) : transfer && isSpotWithdraw(transfer) ? ( + setIsOpen(false)} id={transferId} type="spot" /> ) : null; return ( diff --git a/src/views/dialogs/TransferDialogs/WithdrawDialog2/AddressInput.tsx b/src/views/dialogs/TransferDialogs/WithdrawDialog2/AddressInput.tsx index 921691e02f..7037230a42 100644 --- a/src/views/dialogs/TransferDialogs/WithdrawDialog2/AddressInput.tsx +++ b/src/views/dialogs/TransferDialogs/WithdrawDialog2/AddressInput.tsx @@ -28,6 +28,7 @@ type AddressInputProps = { destinationChain: string; onDestinationClicked: () => void; placeholder?: string; + isChainSelectable?: boolean; }; export const AddressInput = ({ @@ -36,6 +37,7 @@ export const AddressInput = ({ destinationChain, onDestinationClicked, placeholder, + isChainSelectable = true, }: AddressInputProps) => { const stringGetter = useStringGetter(); const { sourceAccount } = useAccounts(); @@ -81,14 +83,14 @@ export const AddressInput = ({ )}
<$ChainButton - disabled={sourceAccount.chain === WalletNetworkType.Solana} + disabled={!isChainSelectable || sourceAccount.chain === WalletNetworkType.Solana} onClick={onDestinationClicked} >
{CHAIN_INFO[destinationChain]?.name}
- {sourceAccount.chain !== WalletNetworkType.Solana && ( + {isChainSelectable && sourceAccount.chain !== WalletNetworkType.Solana && ( <$CaretIcon size="10px" iconName={IconName.Caret} /> )} diff --git a/src/views/dialogs/TransferDialogs/WithdrawDialog2/SpotAmountInput.tsx b/src/views/dialogs/TransferDialogs/WithdrawDialog2/SpotAmountInput.tsx new file mode 100644 index 0000000000..2aac2c3440 --- /dev/null +++ b/src/views/dialogs/TransferDialogs/WithdrawDialog2/SpotAmountInput.tsx @@ -0,0 +1,79 @@ +import { Dispatch, SetStateAction } from 'react'; + +import { STRING_KEYS } from '@/constants/localization'; + +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { Output, OutputType } from '@/components/Output'; + +type SpotAmountInputProps = { + amount: string; + setAmount: Dispatch>; + solBalance: string | undefined; + maxWithdrawable: number | undefined; + isPending: boolean; +}; + +export const SpotAmountInput = ({ + amount, + setAmount, + solBalance, + maxWithdrawable, + isPending, +}: SpotAmountInputProps) => { + const stringGetter = useStringGetter(); + + const onClickMax = () => { + if (maxWithdrawable) { + setAmount(maxWithdrawable.toString()); + } + }; + + return ( +
+
+
+ {stringGetter({ key: STRING_KEYS.AMOUNT })} + + {solBalance && ( + <> + • + + + )} + + {maxWithdrawable !== undefined && maxWithdrawable > 0 && ( + <> + • + + + )} +
+ setAmount(e.target.value)} + /> +
+
+ ); +}; diff --git a/src/views/dialogs/TransferDialogs/WithdrawDialog2/SpotWithdrawForm.tsx b/src/views/dialogs/TransferDialogs/WithdrawDialog2/SpotWithdrawForm.tsx new file mode 100644 index 0000000000..f70e1a391f --- /dev/null +++ b/src/views/dialogs/TransferDialogs/WithdrawDialog2/SpotWithdrawForm.tsx @@ -0,0 +1,220 @@ +import { Dispatch, SetStateAction, useMemo } from 'react'; + +import { ButtonAction } from '@/constants/buttons'; +import { STRING_KEYS } from '@/constants/localization'; +import { SOLANA_MAINNET_ID } from '@/constants/solana'; +import { MIN_SOL_RESERVE, SOLANA_BASE_TRANSACTION_FEE } from '@/constants/spot'; + +import { useAccounts } from '@/hooks/useAccounts'; +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { WarningIcon } from '@/icons'; + +import { Button } from '@/components/Button'; +import { Details } from '@/components/Details'; +import { DiffOutput } from '@/components/DiffOutput'; +import { Output, OutputType } from '@/components/Output'; +import { WithTooltip } from '@/components/WithTooltip'; + +import { useAppDispatch } from '@/state/appTypes'; +import { addSpotWithdraw, SpotWithdraw, updateSpotWithdraw } from '@/state/transfers'; + +import { AttemptBigNumber } from '@/lib/numbers'; + +import { isValidWithdrawalAddress } from '../utils'; +import { AddressInput } from './AddressInput'; +import { SpotAmountInput } from './SpotAmountInput'; +import { useMaxWithdrawableSol, useSolBalance, useWithdrawSol } from './withdrawSpotHooks'; + +// TODO: spot localization + +export const SpotWithdrawForm = ({ + amount, + setAmount, + destinationAddress, + setDestinationAddress, + onWithdrawSigned, +}: { + amount: string; + setAmount: Dispatch>; + destinationAddress: string; + setDestinationAddress: Dispatch>; + onWithdrawSigned: (withdrawId: string) => void; +}) => { + const stringGetter = useStringGetter(); + const dispatch = useAppDispatch(); + const { dydxAddress, solanaAddress } = useAccounts(); + + const { data: solBalance } = useSolBalance(); + const maxWithdrawable = useMaxWithdrawableSol(); + const { mutateAsync: executeWithdraw, isPending } = useWithdrawSol(); + + const amountBN = AttemptBigNumber(amount); + const solBalanceBN = AttemptBigNumber(solBalance); + const updatedBalance = solBalanceBN?.minus(amountBN ?? 0); + + const validationError = useMemo(() => { + if (!destinationAddress || !amount) { + return undefined; + } + + if (!solanaAddress) { + return 'Solana wallet not connected'; + } + + if (!isValidWithdrawalAddress(destinationAddress, SOLANA_MAINNET_ID)) { + return 'Invalid Solana address'; + } + + if (amountBN == null || amountBN.lte(0)) { + return 'Amount must be greater than zero'; + } + + if (solBalanceBN == null) { + return 'Loading balance...'; + } + + if (amountBN.gt(solBalanceBN)) { + return stringGetter({ key: STRING_KEYS.INSUFFICIENT_BALANCE }); + } + + if (amountBN.gt(maxWithdrawable)) { + return `Must keep ${MIN_SOL_RESERVE} SOL reserved for fees`; + } + + return undefined; + }, [ + destinationAddress, + amount, + solanaAddress, + amountBN, + solBalanceBN, + maxWithdrawable, + stringGetter, + ]); + + const withdrawDisabled = !destinationAddress || !amount || !!validationError; + + const handleWithdraw = async () => { + if (withdrawDisabled || !dydxAddress) return; + + const withdrawId = crypto.randomUUID(); + + const spotWithdraw: SpotWithdraw = { + id: withdrawId, + type: 'spot-withdraw', + amount, + destinationAddress, + status: 'pending', + }; + + dispatch(addSpotWithdraw({ withdraw: spotWithdraw, dydxAddress })); + onWithdrawSigned(withdrawId); + + try { + const { signature } = await executeWithdraw({ amount, destinationAddress }); + + dispatch( + updateSpotWithdraw({ + dydxAddress, + withdrawId, + updates: { status: 'success', txSignature: signature }, + }) + ); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + + dispatch( + updateSpotWithdraw({ + dydxAddress, + withdrawId, + updates: { status: 'error', error: errorMessage }, + }) + ); + } + }; + + const receipt = ( +
+ ), + }, + { + key: 'fee', + label: 'Estimated Fee', + value: ( + + ), + }, + ]} + /> + ); + + const buttonInner = validationError ? ( +
+ + + + {stringGetter({ key: STRING_KEYS.WITHDRAW })} +
+ ) : ( + stringGetter({ key: STRING_KEYS.WITHDRAW }) + ); + + return ( +
+ {}} + placeholder={`${stringGetter({ key: STRING_KEYS.ADDRESS })}...`} + isChainSelectable={false} + /> + + + +
+ {receipt} + + +
+
+ ); +}; diff --git a/src/views/dialogs/TransferDialogs/WithdrawDialog2/WithdrawDialog2.tsx b/src/views/dialogs/TransferDialogs/WithdrawDialog2/WithdrawDialog2.tsx index 22c91cea3b..9da39498e5 100644 --- a/src/views/dialogs/TransferDialogs/WithdrawDialog2/WithdrawDialog2.tsx +++ b/src/views/dialogs/TransferDialogs/WithdrawDialog2/WithdrawDialog2.tsx @@ -11,9 +11,11 @@ import { WalletNetworkType, WalletType } from '@/constants/wallets'; import { useAccounts } from '@/hooks/useAccounts'; import { useBreakpoints } from '@/hooks/useBreakpoints'; +import { useEnableSpot } from '@/hooks/useEnableSpot'; import { useStringGetter } from '@/hooks/useStringGetter'; import { Dialog, DialogPlacement } from '@/components/Dialog'; +import { SpotTabItem, SpotTabs } from '@/pages/spot/SpotTabs'; import { useAppDispatch } from '@/state/appTypes'; import { @@ -24,6 +26,7 @@ import { } from '@/state/transfers'; import { ChainSelect } from './ChainSelect'; +import { SpotWithdrawForm } from './SpotWithdrawForm'; import { WithdrawForm } from './WithdrawForm'; import { WithdrawStatus } from './WithdrawStatus'; @@ -47,9 +50,20 @@ export const WithdrawDialog2 = ({ setIsOpen }: DialogProps) const stringGetter = useStringGetter(); const dispatch = useAppDispatch(); const [amount, setAmount] = useState(''); + const [spotDestinationAddress, setSpotDestinationAddress] = useState(''); const [currentWithdrawId, setCurrentWithdrawId] = useState(); + const [currentWithdrawType, setCurrentWithdrawType] = useState<'perps' | 'spot'>('perps'); const [formState, setFormState] = useState<'form' | 'chain-select'>('form'); const chainSelectRef = useRef(null); + const isSpotEnabled = useEnableSpot(); + + const handleTabChange = (newTab: 'perps' | 'spot') => { + setCurrentWithdrawType(newTab); + setAmount(''); + setDestinationAddress(isPrivy || isTurnkey ? '' : (sourceAccount.address ?? '')); + setSpotDestinationAddress(''); + setFormState('form'); + }; const dialogTitle = formState === 'form' @@ -65,6 +79,10 @@ export const WithdrawDialog2 = ({ setIsOpen }: DialogProps) setCurrentWithdrawId(withdrawId); }; + const onSpotWithdrawSigned = (withdrawId: string) => { + setCurrentWithdrawId(withdrawId); + }; + const onWithdraw = (withdraw: Withdraw) => { if (!dydxAddress) return; dispatch(addWithdraw({ withdraw, dydxAddress })); @@ -78,6 +96,64 @@ export const WithdrawDialog2 = ({ setIsOpen }: DialogProps) dispatch(onWithdrawBroadcast({ dydxAddress, withdrawId, subtransaction })); }; + const tabs: SpotTabItem[] = [ + { + value: 'perps', + label: 'Perpetuals', + content: ( +
+
+ setFormState('chain-select')} + onWithdraw={onWithdraw} + onWithdrawBroadcastUpdate={onWithdrawBroadcastUpdate} + onWithdrawSigned={onWithdrawSigned} + /> +
+
+ +
+
+ ), + }, + { + value: 'spot', + label: 'Spot', + content: ( + + ), + }, + ]; + return ( <$Dialog isOpen @@ -89,43 +165,20 @@ export const WithdrawDialog2 = ({ setIsOpen }: DialogProps) placement={isMobile ? DialogPlacement.FullScreen : DialogPlacement.Default} > {currentWithdrawId && ( - setIsOpen(false)} /> + setIsOpen(false)} + /> )} {!currentWithdrawId && ( -
-
-
- setFormState('chain-select')} - onWithdraw={onWithdraw} - onWithdrawBroadcastUpdate={onWithdrawBroadcastUpdate} - onWithdrawSigned={onWithdrawSigned} - /> -
-
- -
-
+
+ handleTabChange(v as 'perps' | 'spot')} + hideTabs={formState === 'chain-select' || !isSpotEnabled} + items={tabs} + />
)} diff --git a/src/views/dialogs/TransferDialogs/WithdrawDialog2/WithdrawForm.tsx b/src/views/dialogs/TransferDialogs/WithdrawDialog2/WithdrawForm.tsx index ede8684d7b..673e1b65b6 100644 --- a/src/views/dialogs/TransferDialogs/WithdrawDialog2/WithdrawForm.tsx +++ b/src/views/dialogs/TransferDialogs/WithdrawDialog2/WithdrawForm.tsx @@ -273,7 +273,7 @@ export const WithdrawForm = ({ }; return ( -
+
void; }; -export const WithdrawStatus = ({ id = '', onClose }: WithdrawStatusProps) => { +export const WithdrawStatus = ({ id = '', type = 'perps', onClose }: WithdrawStatusProps) => { const stringGetter = useStringGetter(); - const withdraw = useAppSelectorWithArgs(selectWithdraw, id); + const perpWithdraw = useAppSelectorWithArgs(selectWithdraw, id); + const spotWithdraw = useAppSelectorWithArgs(selectSpotWithdraw, id); + + const withdraw = type === 'spot' ? spotWithdraw : perpWithdraw; const transferSuccess = withdraw?.status === 'success'; const transferError = withdraw?.status === 'error'; - const transferAssetRelease = withdraw?.transferAssetRelease; + const transferAssetRelease = + type === 'perps' && perpWithdraw ? perpWithdraw.transferAssetRelease : undefined; const statusDescription = useMemo(() => { if (transferSuccess) return stringGetter({ key: STRING_KEYS.YOUR_FUNDS_WITHDRAWN }); @@ -41,35 +46,62 @@ export const WithdrawStatus = ({ id = '', onClose }: WithdrawStatusProps) => { }); } + if (type === 'spot' && spotWithdraw?.error) { + return spotWithdraw.error; + } + return stringGetter({ key: STRING_KEYS.WITHDRAWAL_FAILED_TRY_AGAIN }); } return stringGetter({ key: STRING_KEYS.YOUR_FUNDS_WITHDRAWN_SHORTLY }); - }, [stringGetter, transferAssetRelease, transferError, transferSuccess]); + }, [stringGetter, transferAssetRelease, transferError, transferSuccess, type, spotWithdraw]); const withdrawalOutput = withdraw == null ? ( - ) : ( + ) : type === 'spot' && spotWithdraw ? ( + + ) : perpWithdraw ? ( - ); + ) : null; - const withdrawalExplorerLinks = withdraw?.transactions - .map((t) => { - if (!t.explorerLink) return null; + const withdrawalExplorerLinks = + type === 'spot' && spotWithdraw?.txSignature + ? [ + + {truncateAddress(spotWithdraw.txSignature, '')} + , + ] + : type === 'perps' && perpWithdraw + ? perpWithdraw.transactions + .map((t) => { + if (!t.explorerLink) return null; - return ( - - {truncateAddress(t.txHash, '')} - - ); - }) - .filter(isTruthy); + return ( + + {truncateAddress(t.txHash, '')} + + ); + }) + .filter(isTruthy) + : []; return (
@@ -97,7 +129,11 @@ export const WithdrawStatus = ({ id = '', onClose }: WithdrawStatusProps) => {
{stringGetter({ key: STRING_KEYS.YOUR_WITHDRAWAL })}
{withdrawalOutput} - {withdraw && } + {type === 'spot' ? ( + + ) : ( + perpWithdraw && + )}
- {(withdrawalExplorerLinks?.length ?? 0) > 0 && ( + {withdrawalExplorerLinks.length > 0 && (
{stringGetter({ key: STRING_KEYS.VIEW_TRANSACTIONS_SHORT })} diff --git a/src/views/dialogs/TransferDialogs/WithdrawDialog2/withdrawSpotHooks.ts b/src/views/dialogs/TransferDialogs/WithdrawDialog2/withdrawSpotHooks.ts new file mode 100644 index 0000000000..d6747d205f --- /dev/null +++ b/src/views/dialogs/TransferDialogs/WithdrawDialog2/withdrawSpotHooks.ts @@ -0,0 +1,103 @@ +import { wrapAndLogBonsaiError } from '@/bonsai/logs'; +import { LAMPORTS_PER_SOL, PublicKey, SystemProgram, Transaction } from '@solana/web3.js'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { + MIN_SOL_RESERVE, + SOL_WITHDRAWAL_POLL_INTERVAL_MS, + SOL_WITHDRAWAL_TIMEOUT_MS, +} from '@/constants/spot'; +import { timeUnits } from '@/constants/time'; + +import { useAccounts } from '@/hooks/useAccounts'; +import { useSolanaConnection } from '@/hooks/useSolanaConnection'; + +import { promiseWithTimeout } from '@/lib/asyncUtils'; +import { sleep } from '@/lib/timeUtils'; + +export const useWithdrawSol = () => { + const { localSolanaKeypair, solanaAddress } = useAccounts(); + const connection = useSolanaConnection(); + + return useMutation({ + mutationFn: wrapAndLogBonsaiError( + async ({ amount, destinationAddress }: { amount: string; destinationAddress: string }) => { + if (!localSolanaKeypair || !solanaAddress) { + throw new Error('Solana wallet not available'); + } + + const sourcePubkey = new PublicKey(solanaAddress); + const destPubkey = new PublicKey(destinationAddress); + const lamports = Math.floor(parseFloat(amount) * LAMPORTS_PER_SOL); + + const transaction = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: sourcePubkey, + toPubkey: destPubkey, + lamports, + }) + ); + + transaction.feePayer = sourcePubkey; + const { blockhash } = await connection.getLatestBlockhash(); + transaction.recentBlockhash = blockhash; + + transaction.sign(localSolanaKeypair); + + const signature = await connection.sendRawTransaction(transaction.serialize()); + + const pollForConfirmation = async (): Promise => { + // eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition + while (true) { + // eslint-disable-next-line no-await-in-loop + const status = await connection.getSignatureStatus(signature); + + if ( + status.value?.confirmationStatus === 'confirmed' || + status.value?.confirmationStatus === 'finalized' + ) { + return; + } + + if (status.value?.err) { + throw new Error(`Transaction failed: ${JSON.stringify(status.value.err)}`); + } + + // eslint-disable-next-line no-await-in-loop + await sleep(SOL_WITHDRAWAL_POLL_INTERVAL_MS); + } + }; + + await promiseWithTimeout( + pollForConfirmation(), + SOL_WITHDRAWAL_TIMEOUT_MS, + 'Transaction confirmation timeout' + ); + + return { signature }; + }, + 'spotWithdrawSol' + ), + }); +}; + +export const useSolBalance = () => { + const { solanaAddress } = useAccounts(); + const connection = useSolanaConnection(); + + return useQuery({ + queryKey: ['solBalance', solanaAddress], + queryFn: async () => { + if (!solanaAddress) return '0'; + const lamports = await connection.getBalance(new PublicKey(solanaAddress)); + return (lamports / LAMPORTS_PER_SOL).toString(); + }, + enabled: Boolean(solanaAddress), + refetchInterval: timeUnits.second * 60, + }); +}; + +export const useMaxWithdrawableSol = () => { + const { data: solBalance } = useSolBalance(); + return solBalance ? Math.max(parseFloat(solBalance) - MIN_SOL_RESERVE, 0) : 0; +}; diff --git a/src/views/menus/AccountMenu/AccountMenu.tsx b/src/views/menus/AccountMenu/AccountMenu.tsx index 3ce830bbb0..1ac4873471 100644 --- a/src/views/menus/AccountMenu/AccountMenu.tsx +++ b/src/views/menus/AccountMenu/AccountMenu.tsx @@ -1,35 +1,31 @@ import { ElementType, useMemo } from 'react'; +import { BonsaiCore } from '@/bonsai/ontology'; import { useMfaEnrollment, usePrivy } from '@privy-io/react-auth'; +import { LAMPORTS_PER_SOL } from '@solana/web3.js'; import styled, { css } from 'styled-components'; import tw from 'twin.macro'; import { AMOUNT_RESERVED_FOR_GAS_USDC, OnboardingState } from '@/constants/account'; -import { ButtonAction, ButtonShape, ButtonSize, ButtonType } from '@/constants/buttons'; +import { ButtonAction, ButtonShape, ButtonSize } from '@/constants/buttons'; import { DialogTypes } from '@/constants/dialogs'; -import { STRING_KEYS, TOOLTIP_STRING_KEYS } from '@/constants/localization'; +import { STRING_KEYS } from '@/constants/localization'; import { isDev } from '@/constants/networks'; import { SMALL_USD_DECIMALS, USD_DECIMALS } from '@/constants/numbers'; import { StatsigFlags } from '@/constants/statsig'; -import { - ConnectorType, - DydxChainAsset, - WalletNetworkType, - wallets, - WalletType, -} from '@/constants/wallets'; +import { ConnectorType, DydxChainAsset, wallets, WalletType } from '@/constants/wallets'; import { useAccountBalance } from '@/hooks/useAccountBalance'; import { useAccounts } from '@/hooks/useAccounts'; import { useBreakpoints } from '@/hooks/useBreakpoints'; import { useComplianceState } from '@/hooks/useComplianceState'; +import { useEnableSpot } from '@/hooks/useEnableSpot'; import { useEnvFeatures } from '@/hooks/useEnvFeatures'; import { useMobileAppUrl } from '@/hooks/useMobileAppUrl'; import { useStatsigGateValue } from '@/hooks/useStatsig'; import { useStringGetter } from '@/hooks/useStringGetter'; import { useSubaccount } from '@/hooks/useSubaccount'; import { useTokenConfigs } from '@/hooks/useTokenConfigs'; -import { useURLConfigs } from '@/hooks/useURLConfigs'; import { AppleIcon, AppleLightIcon, DiscordIcon, GoogleIcon, TwitterIcon } from '@/icons'; import { headerMixins } from '@/styles/headerMixins'; @@ -43,7 +39,6 @@ import { IconButton } from '@/components/IconButton'; import { Output, OutputType } from '@/components/Output'; import { Tag, TagSign } from '@/components/Tag'; import { WalletIcon } from '@/components/WalletIcon'; -import { WithTooltip } from '@/components/WithTooltip'; import { MobileDownloadLinks } from '@/views/MobileDownloadLinks'; import { OnboardingTriggerButton } from '@/views/dialogs/OnboardingTriggerButton'; @@ -58,20 +53,24 @@ import { isTruthy } from '@/lib/isTruthy'; import { MustBigNumber } from '@/lib/numbers'; import { truncateAddress } from '@/lib/wallet'; +import { SpotActions } from './SpotActions'; import { SubaccountActions } from './SubaccountActions'; import { WalletActions } from './WalletActions'; +// TODO: spot localization + export const AccountMenu = () => { const stringGetter = useStringGetter(); - const { mintscanBase } = useURLConfigs(); const { isTablet } = useBreakpoints(); const { complianceState } = useComplianceState(); const affiliatesEnabled = useStatsigGateValue(StatsigFlags.ffEnableAffiliates); + const spotEnabled = useEnableSpot(); const dispatch = useAppDispatch(); const onboardingState = useAppSelector(getOnboardingState); const freeCollateral = useAppSelector(getSubaccountFreeCollateral); const isKeplr = useAppSelector(selectIsKeplrConnected); const isTurnkey = useAppSelector(selectIsTurnkeyConnected); + const spotWalletData = useAppSelector(BonsaiCore.spot.walletPositions.data); const { nativeTokenBalance, usdcBalance } = useAccountBalance(); @@ -80,19 +79,14 @@ export const AccountMenu = () => { const { debugCompliance } = useEnvFeatures(); const { - sourceAccount: { walletInfo, address }, + sourceAccount: { walletInfo }, dydxAddress, hdKey, + solanaAddress, + canDeriveSolanaWallet, } = useAccounts(); const { registerAffiliate } = useSubaccount(); - let displayAddress: string | undefined; - if (walletInfo?.name === WalletType.Phantom) { - displayAddress = truncateAddress(address, ''); - } else { - displayAddress = truncateAddress(address, '0x'); - } - const privy = usePrivy(); const { google, discord, twitter } = privy.user ?? {}; @@ -178,48 +172,35 @@ export const AccountMenu = () => { slotTopContent={ onboardingState === OnboardingState.AccountConnected && (
- <$AddressRow> - - <$Column> - {walletInfo && walletInfo.name !== WalletType.Keplr ? ( - - ) : ( - <$label>{stringGetter({ key: STRING_KEYS.DYDX_CHAIN_ADDRESS })} - )} - <$Address>{truncateAddress(dydxAddress)} - - <$CopyButton buttonType="icon" value={dydxAddress} shape={ButtonShape.Square} /> - - <$IconButton +
+ {!!walletInfo && canDeriveSolanaWallet && spotEnabled && solanaAddress && ( + <$AddressCopyButton + value={solanaAddress} + size={ButtonSize.XSmall} + shape={ButtonShape.Pill} + copyIconPosition="end" action={ButtonAction.Base} - href={`${mintscanBase}/account/${dydxAddress}`} - iconName={IconName.LinkOut} - shape={ButtonShape.Square} - type={ButtonType.Link} - /> - - - {walletInfo && - walletInfo.name !== WalletType.Privy && - walletInfo.name !== WalletType.Keplr && ( - <$AddressRow> -
- - {walletIcon} -
- <$Column> - <$label>{stringGetter({ key: STRING_KEYS.SOURCE_ADDRESS })} - <$Address>{displayAddress} - - + > + + {stringGetter({ key: STRING_KEYS.SPOT })} + )} + <$AddressCopyButton + value={dydxAddress} + size={ButtonSize.XSmall} + shape={ButtonShape.Pill} + copyIconPosition="end" + action={ButtonAction.Base} + > + + {stringGetter({ key: STRING_KEYS.PERPETUALS })} + +
+ <$Balances>
@@ -287,6 +268,25 @@ export const AccountMenu = () => { withOnboarding />
+ {canDeriveSolanaWallet && spotEnabled && ( +
+
+ <$label> + Spot Sol Balance + + + <$BalanceOutput + type={OutputType.Asset} + value={ + spotWalletData?.solBalance + ? spotWalletData.solBalance / LAMPORTS_PER_SOL + : 0 + } + /> +
+ +
+ )} {showConfirmPendingDeposit && ( <$ConfirmPendingDeposit> @@ -452,59 +452,9 @@ export const AccountMenu = () => { ); }; -const DydxDerivedAddress = ({ - address, - chain, - dydxAddress, -}: { - address?: string; - chain?: WalletNetworkType.Solana | WalletNetworkType.Evm; - dydxAddress?: string; -}) => { - const stringGetter = useStringGetter(); - - const tooltipText = - chain === WalletNetworkType.Solana - ? stringGetter({ - key: TOOLTIP_STRING_KEYS.DYDX_ADDRESS_FROM_SOLANA_BODY, - params: { - DYDX_ADDRESS: {truncateAddress(dydxAddress)}, - SOLANA_ADDRESS: truncateAddress(address, ''), - }, - }) - : stringGetter({ - key: TOOLTIP_STRING_KEYS.DYDX_ADDRESS_FROM_ETHEREUM_BODY, - params: { - DYDX_ADDRESS: {truncateAddress(dydxAddress)}, - EVM_ADDRESS: truncateAddress(address, '0x'), - }, - }); - - return ( - -
{tooltipText}
- - } - > - <$label>{stringGetter({ key: STRING_KEYS.DYDX_CHAIN_ADDRESS })} -
- ); -}; - const $Column = styled.div` ${layoutMixins.column} `; -const $AddressRow = styled.div` - ${layoutMixins.row} - - gap: 0.5rem; - - ${$Column} { - margin-right: 0.5rem; - } -`; const $label = styled.div` ${layoutMixins.row} @@ -581,7 +531,6 @@ const $IconButton = styled(IconButton)` `} `; -const $CopyButton = styled(CopyButton)` - --button-padding: 0 0.25rem; - --button-border: solid var(--border-width) var(--color-layer-6); +const $AddressCopyButton = styled(CopyButton)` + --button-padding: 0 0.375rem; `; diff --git a/src/views/menus/AccountMenu/SpotActions.tsx b/src/views/menus/AccountMenu/SpotActions.tsx new file mode 100644 index 0000000000..dae0e9e7d7 --- /dev/null +++ b/src/views/menus/AccountMenu/SpotActions.tsx @@ -0,0 +1,74 @@ +import { memo } from 'react'; + +import { Item } from '@radix-ui/react-dropdown-menu'; +import styled, { css } from 'styled-components'; + +import { ButtonAction, ButtonShape } from '@/constants/buttons'; +import { DialogTypes } from '@/constants/dialogs'; +import { STRING_KEYS } from '@/constants/localization'; + +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { IconName } from '@/components/Icon'; +import { IconButton } from '@/components/IconButton'; +import { WithTooltip } from '@/components/WithTooltip'; +import { useMaxWithdrawableSol } from '@/views/dialogs/TransferDialogs/WithdrawDialog2/withdrawSpotHooks'; + +import { useAppDispatch } from '@/state/appTypes'; +import { openDialog } from '@/state/dialogs'; + +import { isTruthy } from '@/lib/isTruthy'; + +export const SpotActions = memo(() => { + const dispatch = useAppDispatch(); + const stringGetter = useStringGetter(); + const maxWithdrawable = useMaxWithdrawableSol(); + const hasBalance = maxWithdrawable > 0; + + return ( +
+ {[ + { + dialog: DialogTypes.Deposit2({}), + iconName: IconName.Deposit, + tooltipStringKey: STRING_KEYS.DEPOSIT, + }, + hasBalance && { + dialog: DialogTypes.Withdraw2({}), + iconName: IconName.Withdraw, + tooltipStringKey: STRING_KEYS.WITHDRAW, + }, + ] + .filter(isTruthy) + .map(({ iconName, tooltipStringKey, dialog }) => ( + + + <$IconButton + key={dialog.type} + action={ButtonAction.Base} + shape={ButtonShape.Square} + iconName={iconName} + onClick={() => dispatch(openDialog(dialog))} + /> + + + ))} +
+ ); +}); + +const $IconButton = styled(IconButton)` + --button-padding: 0 0.25rem; + --button-border: solid var(--border-width) var(--color-layer-6); + + ${({ iconName }) => + iconName != null && + [IconName.Withdraw, IconName.Deposit].includes(iconName) && + css` + --button-icon-size: 1.375em; + `} +`; diff --git a/src/views/tables/MarketsTable/FavoriteButton.tsx b/src/views/tables/MarketsTable/FavoriteButton.tsx index bc91bb2ab0..baa7241779 100644 --- a/src/views/tables/MarketsTable/FavoriteButton.tsx +++ b/src/views/tables/MarketsTable/FavoriteButton.tsx @@ -9,28 +9,45 @@ import { useAppSelectorWithArgs } from '@/hooks/useParameterizedSelector'; import { IconName } from '@/components/Icon'; import { IconButton } from '@/components/IconButton'; -import { favoriteMarket, unfavoriteMarket } from '@/state/appUiConfigs'; -import { getIsMarketFavorited } from '@/state/appUiConfigsSelectors'; +import { + favoriteMarket, + favoriteSpotToken, + unfavoriteMarket, + unfavoriteSpotToken, +} from '@/state/appUiConfigs'; +import { getIsMarketFavorited, getIsSpotTokenFavorited } from '@/state/appUiConfigsSelectors'; import { track } from '@/lib/analytics/analytics'; export const FavoriteButton = ({ className, marketId, + variant = 'perp', }: { className?: string; marketId: string; + variant?: 'perp' | 'spot'; }) => { const dispatch = useDispatch(); - const isMarketFavorited = useAppSelectorWithArgs(getIsMarketFavorited, marketId); + const isSpotFavorited = useAppSelectorWithArgs(getIsSpotTokenFavorited, marketId); + const isPerpFavorited = useAppSelectorWithArgs(getIsMarketFavorited, marketId); + const isMarketFavorited = variant === 'spot' ? isSpotFavorited : isPerpFavorited; const onToggle = (newIsFavorited: boolean) => { if (newIsFavorited) { - dispatch(favoriteMarket(marketId)); - track(AnalyticsEvents.FavoriteMarket({ marketId })); + if (variant === 'spot') { + dispatch(favoriteSpotToken(marketId)); + } else { + dispatch(favoriteMarket(marketId)); + track(AnalyticsEvents.FavoriteMarket({ marketId })); + } } else { - dispatch(unfavoriteMarket(marketId)); - track(AnalyticsEvents.UnfavoriteMarket({ marketId })); + if (variant === 'spot') { + dispatch(unfavoriteSpotToken(marketId)); + } else { + dispatch(unfavoriteMarket(marketId)); + track(AnalyticsEvents.UnfavoriteMarket({ marketId })); + } } }; diff --git a/tailwind.config.js b/tailwind.config.js index 30e35a85ce..57b253e4e9 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -19,6 +19,8 @@ export default { black: 'var(--color-black)', green: 'var(--color-green)', red: 'var(--color-red)', + 'red-faded': 'var(--color-red-faded)', + 'green-faded': 'var(--color-green-faded)', 'white-faded': 'var(--color-white-faded)', 'color-layer-0': 'var(--color-layer-0)', @@ -130,6 +132,7 @@ export default { }, borderRadius: ({ theme }) => ({ ...theme('spacing'), + full: '9999px', }), extend: { animation: { From cd4e1a29c4b6494e8bf74be7d654cd121375f4b2 Mon Sep 17 00:00:00 2001 From: Paata Kardava Date: Tue, 9 Dec 2025 21:32:18 +0400 Subject: [PATCH 08/13] fix: spot tv price formatting (#2012) --- src/lib/tradingView/spotDatafeed/index.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/lib/tradingView/spotDatafeed/index.ts b/src/lib/tradingView/spotDatafeed/index.ts index 9b796956c0..789853c039 100644 --- a/src/lib/tradingView/spotDatafeed/index.ts +++ b/src/lib/tradingView/spotDatafeed/index.ts @@ -48,9 +48,11 @@ export const getSpotDatafeed = (store: RootStore, spotApiUrl: string): IBasicDat }, resolveSymbol: async (tokenMint, onSymbolResolvedCallback) => { - const tokenPrice = await waitForSelector(store, (s) => BonsaiCore.spot.tokenPrice.data(s)); - const symbolInfo = createSpotSymbolInfo(tokenMint, tokenPrice); - setTimeout(() => onSymbolResolvedCallback(symbolInfo), 0); + setTimeout(async () => { + const tokenPrice = await waitForSelector(store, (s) => BonsaiCore.spot.tokenPrice.data(s)); + const symbolInfo = createSpotSymbolInfo(tokenMint, tokenPrice); + onSymbolResolvedCallback(symbolInfo); + }, 0); }, getBars: async (symbolInfo, resolution, periodParams, onHistoryCallback, onErrorCallback) => { From 59cbb0416a4858bd1d49bb7f5321ad2b76927a8b Mon Sep 17 00:00:00 2001 From: Luka Buzaladze Date: Wed, 10 Dec 2025 10:58:51 -0500 Subject: [PATCH 09/13] feat: add dollar rewards (#2010) --- package.json | 4 ++-- pnpm-lock.yaml | 8 ++++---- src/hooks/rewards/hooks.ts | 2 +- src/pages/token/CompetitionIncentivesPanel.tsx | 2 +- src/pages/token/CompetitionLeaderboardPanel.tsx | 13 +++++++++++++ src/pages/token/RewardsPage.tsx | 2 +- 6 files changed, 22 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 0bbe8ac8d7..00f01d0c17 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "@cosmjs/tendermint-rpc": "^0.32.1", "@datadog/browser-logs": "^5.23.3", "@dydxprotocol/v4-client-js": "3.2.0", - "@dydxprotocol/v4-localization": "1.1.364", + "@dydxprotocol/v4-localization": "1.1.365", "@dydxprotocol/v4-proto": "^7.0.0-dev.0", "@emotion/is-prop-valid": "^1.3.0", "@hugocxl/react-to-image": "^0.0.9", @@ -238,4 +238,4 @@ "includeClassNames": true } } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 265fc036fe..37275b055e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,8 +33,8 @@ dependencies: specifier: 3.2.0 version: 3.2.0 '@dydxprotocol/v4-localization': - specifier: 1.1.364 - version: 1.1.364 + specifier: 1.1.365 + version: 1.1.365 '@dydxprotocol/v4-proto': specifier: ^7.0.0-dev.0 version: 7.0.5 @@ -1702,8 +1702,8 @@ packages: - utf-8-validate dev: false - /@dydxprotocol/v4-localization@1.1.364: - resolution: {integrity: sha512-O0pmALR1Rpvn0Crj6GRHvZqd+nv947+RAVsay8mGQt3RRIQN0Cers32N0dUszEeBALKmaZUnXoFV4q/UWFE4Cw==} + /@dydxprotocol/v4-localization@1.1.365: + resolution: {integrity: sha512-ea+HKdKlInDCgIVtaWhZrviDvAqSsbEBKo3aqrXWM2cpS0RV9VK0/SPO/NSru+tQJuBCeTji1yByR0gCHACFbg==} dev: false /@dydxprotocol/v4-proto@7.0.5: diff --git a/src/hooks/rewards/hooks.ts b/src/hooks/rewards/hooks.ts index eb7c501943..b62b08c814 100644 --- a/src/hooks/rewards/hooks.ts +++ b/src/hooks/rewards/hooks.ts @@ -141,7 +141,7 @@ export type ChaosLabsPnlItem = { async function getChaosLabsPnlDistribution() { const res = await fetch( - `https://pp-external-api-ffb2ad95ef03.herokuapp.com/api/dydx-weekly-clc?perPage=100`, + `https://pp-external-api-ffb2ad95ef03.herokuapp.com/api/dydx-weekly-clc?perPage=1000`, { method: 'GET', } diff --git a/src/pages/token/CompetitionIncentivesPanel.tsx b/src/pages/token/CompetitionIncentivesPanel.tsx index 9dd110be9c..1dc9fbdf45 100644 --- a/src/pages/token/CompetitionIncentivesPanel.tsx +++ b/src/pages/token/CompetitionIncentivesPanel.tsx @@ -124,7 +124,7 @@ const Sept2025RewardsPanel = () => { })} slotTrigger={
- {stringGetter({ key: STRING_KEYS.MONTHLY_PRIZE })} + {stringGetter({ key: STRING_KEYS.ESTIMATED_PRIZE })}
} diff --git a/src/pages/token/CompetitionLeaderboardPanel.tsx b/src/pages/token/CompetitionLeaderboardPanel.tsx index bdc6384d4e..ae53ede393 100644 --- a/src/pages/token/CompetitionLeaderboardPanel.tsx +++ b/src/pages/token/CompetitionLeaderboardPanel.tsx @@ -25,6 +25,7 @@ export enum RewardsLeaderboardTableColumns { Rank = 'Rank', Trader = 'Trader', PNL = 'PNL', + EstimatedPrize = 'EstimatedPrize', } export const CompetitionLeaderboardPanel = () => { @@ -271,6 +272,18 @@ const getRewardsLeaderboardTableColumnDef = ({ /> ), }, + [RewardsLeaderboardTableColumns.EstimatedPrize]: { + columnKey: RewardsLeaderboardTableColumns.EstimatedPrize, + getCellValue: (row) => row.dollarReward, + label: ( +
+ {stringGetter({ key: STRING_KEYS.ESTIMATED_PRIZE })} +
+ ), + renderCell: ({ dollarReward }) => ( + + ), + }, } satisfies Record> )[key], }); diff --git a/src/pages/token/RewardsPage.tsx b/src/pages/token/RewardsPage.tsx index 47c9d244ed..b243bf3c75 100644 --- a/src/pages/token/RewardsPage.tsx +++ b/src/pages/token/RewardsPage.tsx @@ -121,7 +121,7 @@ const RewardsPage = () => {
), - label: stringGetter({ key: STRING_KEYS.COMPETITIONS }), + label: stringGetter({ key: STRING_KEYS.REBATES }), value: Tab.Competition, }, ]} From 6d3b8e3cee62d12031b2dd62344b1784d01fb729 Mon Sep 17 00:00:00 2001 From: Paata Kardava Date: Wed, 10 Dec 2025 21:24:09 +0400 Subject: [PATCH 10/13] feat: spot feedback & missing features (#2014) --- src/bonsai/forms/spot.ts | 25 ++ src/components/AssetIcon.tsx | 19 +- src/components/InfoGrid.tsx | 2 +- src/constants/account.ts | 12 + src/constants/spot.ts | 1 + src/hooks/useSpotTransactionSubmit.ts | 1 + src/layout/Header/HeaderDesktop.tsx | 9 +- src/pages/spot/Spot.tsx | 12 +- src/pages/spot/SpotFormInput.tsx | 21 +- src/pages/spot/SpotMarketStatsRow.tsx | 3 +- src/pages/spot/SpotMarketsDropdown.tsx | 10 +- src/pages/spot/SpotTradeForm.tsx | 48 ++-- src/state/accountCalculators.ts | 20 +- src/state/spotFormSelectors.ts | 23 +- src/styles/constants.css | 1 + .../TransferDialogs/DepositAddressCard.tsx | 118 ++++++++ .../TransferDialogs/DepositAddressDialog.tsx | 268 +++++++----------- .../DepositDialog2/DepositDialog2.tsx | 74 +++-- .../DepositForm/DepositForm.tsx | 2 +- .../DepositDialog2/SpotDepositForm.tsx | 28 ++ .../WithdrawDialog2/WithdrawDialog2.tsx | 4 +- 21 files changed, 468 insertions(+), 233 deletions(-) create mode 100644 src/views/dialogs/TransferDialogs/DepositAddressCard.tsx create mode 100644 src/views/dialogs/TransferDialogs/DepositDialog2/SpotDepositForm.tsx diff --git a/src/bonsai/forms/spot.ts b/src/bonsai/forms/spot.ts index caac4cbdab..0eb4b24edf 100644 --- a/src/bonsai/forms/spot.ts +++ b/src/bonsai/forms/spot.ts @@ -1,5 +1,6 @@ import { LAMPORTS_PER_SOL } from '@solana/web3.js'; +import { SpotWalletStatus } from '@/constants/account'; import { MIN_SOL_RESERVE } from '@/constants/spot'; import { SpotApiCreateTransactionRequest, SpotApiSide, SpotApiTradeRoute } from '@/clients/spotApi'; @@ -75,6 +76,7 @@ export interface SpotFormInputData { isReady: boolean; isAsyncDataReady: boolean; isRestReady: boolean; + walletStatus: SpotWalletStatus; } export interface SpotAmounts { @@ -185,6 +187,29 @@ export function getErrors( ): ValidationError[] { const validationErrors: ValidationError[] = []; + if (inputData.walletStatus === SpotWalletStatus.Disconnected) { + validationErrors.push( + simpleValidationError({ + code: 'SPOT_WALLET_DISCONNECTED', + type: ErrorType.error, + titleFallback: 'Connect Wallet', + }) + ); + return validationErrors; + } + + if (inputData.walletStatus === SpotWalletStatus.Unsupported) { + validationErrors.push( + simpleValidationError({ + code: 'SPOT_WALLET_UNSUPPORTED', + type: ErrorType.error, + titleFallback: 'Unsupported Wallet', + textFallback: 'Spot trading requires an EVM or Solana wallet', + }) + ); + return validationErrors; + } + const parsedSize = AttemptNumber(state.size); if (parsedSize == null || parsedSize <= 0) { diff --git a/src/components/AssetIcon.tsx b/src/components/AssetIcon.tsx index ea20cc1916..7458bae31e 100644 --- a/src/components/AssetIcon.tsx +++ b/src/components/AssetIcon.tsx @@ -1,4 +1,4 @@ -import { useContext, useState } from 'react'; +import { useContext, useMemo, useState } from 'react'; import styled, { css } from 'styled-components'; @@ -38,16 +38,26 @@ export const AssetIcon = ({ const [isError, setIsError] = useState(false); const isAssetIconLoading = useContext(LoadingContext); + const placeHolderElement = useMemo( + () => ( + <$Container className={className} $hasChainIcon={!!chainId}> + + {chainId && <$ChainIcon src={CHAIN_INFO[chainId]?.icon} alt={CHAIN_INFO[chainId]?.name} />} + + ), + [chainId, className, symbol] + ); + if (isLoading || isAssetIconLoading) { return <$LoadingAssetIcon className={className} />; } if (isError || (!logoUrl && !isAssetSymbol(symbol))) { - return ; + return placeHolderElement; } return logoUrl ? ( - <$Container className={className}> + <$Container className={className} $hasChainIcon={!!chainId}> <$ContainerBackground /> <$AssetIcon src={logoUrl} @@ -62,6 +72,7 @@ export const AssetIcon = ({ } }} /> + {chainId && <$ChainIcon src={CHAIN_INFO[chainId]?.icon} alt={CHAIN_INFO[chainId]?.name} />} ) : isAssetSymbol(symbol) ? ( <$Container className={className} $hasChainIcon={!!chainId}> @@ -70,7 +81,7 @@ export const AssetIcon = ({ {chainId && <$ChainIcon src={CHAIN_INFO[chainId]?.icon} alt={CHAIN_INFO[chainId]?.name} />} ) : ( - + placeHolderElement ); }; diff --git a/src/components/InfoGrid.tsx b/src/components/InfoGrid.tsx index 0e79fbfd3c..519190e574 100644 --- a/src/components/InfoGrid.tsx +++ b/src/components/InfoGrid.tsx @@ -53,7 +53,7 @@ const $Card = styled.div` justify-content: center; gap: 0.25rem; padding: 0.85rem; - border: solid var(--border-width) var(--color-border); + border: solid var(--border-width) var(--color-layer-4); border-radius: 0.5rem; `; diff --git a/src/constants/account.ts b/src/constants/account.ts index 86669d0de3..73cfa2d64e 100644 --- a/src/constants/account.ts +++ b/src/constants/account.ts @@ -36,6 +36,18 @@ export enum EvmDerivedAccountStatus { Derived, } +/** + * @description Wallet status for spot trading + * - Disconnected: wallet not connected + * - Unsupported: Cosmos wallets can't derive Solana address + * - Connected: EVM/Solana/Turnkey wallets that can trade spot + */ +export enum SpotWalletStatus { + Disconnected = 'Disconnected', + Unsupported = 'Unsupported', + Connected = 'Connected', +} + export type EvmDerivedAddresses = { version?: string; [EvmAddress: EvmAddress]: { diff --git a/src/constants/spot.ts b/src/constants/spot.ts index 6ab38db2e0..7abfadc55d 100644 --- a/src/constants/spot.ts +++ b/src/constants/spot.ts @@ -6,3 +6,4 @@ export const MIN_SOL_RESERVE = 0.002; export const SOLANA_BASE_TRANSACTION_FEE = 0.000005; export const SOL_WITHDRAWAL_POLL_INTERVAL_MS = 1000; export const SOL_WITHDRAWAL_TIMEOUT_MS = 30000; +export const SPOT_DEFAULT_TOKEN_MINT = 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263'; diff --git a/src/hooks/useSpotTransactionSubmit.ts b/src/hooks/useSpotTransactionSubmit.ts index 2458aae0ee..64ff8cdcaa 100644 --- a/src/hooks/useSpotTransactionSubmit.ts +++ b/src/hooks/useSpotTransactionSubmit.ts @@ -52,6 +52,7 @@ export const useSpotTransactionSubmit = () => { ? SpotApiLandingMethod.JUPITER : undefined, jupiterRequestId: createResponse.metadata.jupiterRequestId, + walletAddress: solanaAddress, }); return { diff --git a/src/layout/Header/HeaderDesktop.tsx b/src/layout/Header/HeaderDesktop.tsx index d7d3fa8080..437f2b9e39 100644 --- a/src/layout/Header/HeaderDesktop.tsx +++ b/src/layout/Header/HeaderDesktop.tsx @@ -7,10 +7,12 @@ import { ComplianceStates } from '@/constants/compliance'; import { DialogTypes } from '@/constants/dialogs'; import { STRING_KEYS } from '@/constants/localization'; import { AppRoute } from '@/constants/routes'; +import { SPOT_DEFAULT_TOKEN_MINT } from '@/constants/spot'; import { StatsigFlags } from '@/constants/statsig'; import { useAccounts } from '@/hooks/useAccounts'; import { useComplianceState } from '@/hooks/useComplianceState'; +import { useEnableSpot } from '@/hooks/useEnableSpot'; import { useStatsigGateValue } from '@/hooks/useStatsig'; import { useStringGetter } from '@/hooks/useStringGetter'; import { useTokenConfigs } from '@/hooks/useTokenConfigs'; @@ -39,7 +41,6 @@ import { getHasSeenLaunchIncentives } from '@/state/appUiConfigsSelectors'; import { openDialog } from '@/state/dialogs'; import { isTruthy } from '@/lib/isTruthy'; -import { testFlags } from '@/lib/testFlags'; export const HeaderDesktop = () => { const stringGetter = useStringGetter(); @@ -50,6 +51,7 @@ export const HeaderDesktop = () => { const { dydxAccounts } = useAccounts(); const onboardingState = useAppSelector(getOnboardingState); const { complianceState } = useComplianceState(); + const isSpotEnabled = useEnableSpot(); const affiliatesEnabled = useStatsigGateValue(StatsigFlags.ffEnableAffiliates); const hasSeenLaunchIncentives = useAppSelector(getHasSeenLaunchIncentives); @@ -63,11 +65,10 @@ export const HeaderDesktop = () => { label: stringGetter({ key: STRING_KEYS.TRADE }), href: AppRoute.Trade, }, - // TODO(spot): Localize - testFlags.spot && { + isSpotEnabled && { value: 'SPOT', label: stringGetter({ key: STRING_KEYS.SPOT }), - href: `${AppRoute.Spot}/pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn`, + href: `${AppRoute.Spot}/${SPOT_DEFAULT_TOKEN_MINT}`, }, { value: 'MARKETS', diff --git a/src/pages/spot/Spot.tsx b/src/pages/spot/Spot.tsx index 1a35b22327..b4e30a28e2 100644 --- a/src/pages/spot/Spot.tsx +++ b/src/pages/spot/Spot.tsx @@ -1,11 +1,12 @@ import { useCallback, useMemo, useState } from 'react'; -import { SpotSide } from '@/bonsai/forms/spot'; +import { SpotSellInputType, SpotSide } from '@/bonsai/forms/spot'; import { BonsaiCore } from '@/bonsai/ontology'; import { keyBy } from 'lodash'; import { useNavigate, useParams } from 'react-router-dom'; import styled, { css } from 'styled-components'; +import { SpotWalletStatus } from '@/constants/account'; import { HORIZONTAL_PANEL_MAX_HEIGHT, HORIZONTAL_PANEL_MIN_HEIGHT, @@ -23,6 +24,7 @@ import { IconName } from '@/components/Icon'; import { Output, OutputType } from '@/components/Output'; import { SpotTvChart } from '@/views/charts/TradingView/SpotTvChart'; +import { calculateSpotWalletStatus } from '@/state/accountCalculators'; import { useAppDispatch, useAppSelector } from '@/state/appTypes'; import { setHorizontalPanelHeightPx } from '@/state/appUiConfigs'; import { getHorizontalPanelHeightPx } from '@/state/appUiConfigsSelectors'; @@ -56,13 +58,15 @@ const SpotPage = () => { const tokenBalances = useAppSelector(BonsaiCore.spot.walletPositions.tokenBalances); const walletPositions = useAppSelector(BonsaiCore.spot.walletPositions.positions); const portfolioTrades = useAppSelector(BonsaiCore.spot.portfolioTrades.data); + const walletStatus = useAppSelector(calculateSpotWalletStatus); + const canLoadSpotData = walletStatus === SpotWalletStatus.Connected; const isTokenMetadataLoading = useAppSelector(BonsaiCore.spot.tokenMetadata.loading) !== 'success'; const isWalletPositionsLoading = - useAppSelector(BonsaiCore.spot.walletPositions.loading) !== 'success'; + useAppSelector(BonsaiCore.spot.walletPositions.loading) !== 'success' && canLoadSpotData; const isPortfolioTradesLoading = - useAppSelector(BonsaiCore.spot.portfolioTrades.loading) !== 'success'; + useAppSelector(BonsaiCore.spot.portfolioTrades.loading) !== 'success' && canLoadSpotData; const horizontalPanelHeightPxBase = useAppSelector(getHorizontalPanelHeightPx); const setPanelHeight = useCallback( @@ -239,6 +243,8 @@ const SpotPage = () => { const handlePositionSell = (token: SpotPositionItem) => { dispatch(spotFormActions.setSide(SpotSide.SELL)); + dispatch(spotFormActions.setSellInputType(SpotSellInputType.PERCENT)); + dispatch(spotFormActions.setSize('100')); navigate(`/spot/${token.tokenAddress}`); }; diff --git a/src/pages/spot/SpotFormInput.tsx b/src/pages/spot/SpotFormInput.tsx index f260629dd6..8122a6bb6e 100644 --- a/src/pages/spot/SpotFormInput.tsx +++ b/src/pages/spot/SpotFormInput.tsx @@ -1,10 +1,12 @@ import { forwardRef, useCallback, useId, useImperativeHandle, useMemo, useRef } from 'react'; import { SpotBuyInputType, SpotSellInputType, SpotSide } from '@/bonsai/forms/spot'; +import { useDispatch } from 'react-redux'; import styled from 'styled-components'; import { AlertType } from '@/constants/alerts'; import { ButtonAction, ButtonSize, ButtonStyle } from '@/constants/buttons'; +import { DialogTypes } from '@/constants/dialogs'; import { PERCENT_DECIMALS, TOKEN_DECIMALS, USD_DECIMALS } from '@/constants/numbers'; import { AlertMessage } from '@/components/AlertMessage'; @@ -14,6 +16,8 @@ import { Input, InputProps, InputType } from '@/components/Input'; import { Output, OutputProps, OutputType } from '@/components/Output'; import { TabGroup, TabOption } from '@/components/TabGroup'; +import { openDialog } from '@/state/dialogs'; + const INPUT_CONFIG_MAP = { [SpotSide.BUY]: { [SpotBuyInputType.SOL]: { @@ -72,6 +76,7 @@ export type SpotFormInputProps = { side: SpotSide; inputType: SpotBuyInputType | SpotSellInputType; onInputTypeChange: (side: SpotSide, inputType: SpotBuyInputType | SpotSellInputType) => void; + onBalanceClick?: (value: string) => void; validationConfig?: { type: AlertType; message: string; @@ -91,12 +96,14 @@ export const SpotFormInput = forwardRef( tokenSymbol, tokenAmount, onInputTypeChange, + onBalanceClick, ...inputProps }, ref ) => { const id = useId(); const internalRef = useRef(null); + const dispatch = useDispatch(); useImperativeHandle(ref, () => internalRef.current!); @@ -160,13 +167,25 @@ export const SpotFormInput = forwardRef(
- + {side === SpotSide.BUY && ( dispatch(openDialog(DialogTypes.Deposit2({})))} /> )}
diff --git a/src/pages/spot/SpotMarketStatsRow.tsx b/src/pages/spot/SpotMarketStatsRow.tsx index 774d097070..b5a6f05eb7 100644 --- a/src/pages/spot/SpotMarketStatsRow.tsx +++ b/src/pages/spot/SpotMarketStatsRow.tsx @@ -104,6 +104,7 @@ export const SpotMarketStatsRow = ({ stats, isLoading = false }: SpotMarketStats const $Details = styled(Details)` ${layoutMixins.scrollArea} ${layoutMixins.row} - isolation: isolate; + + isolation: isolate; font: var(--font-mini-book); ` as typeof Details; diff --git a/src/pages/spot/SpotMarketsDropdown.tsx b/src/pages/spot/SpotMarketsDropdown.tsx index 1be310e3eb..e2ecdb895b 100644 --- a/src/pages/spot/SpotMarketsDropdown.tsx +++ b/src/pages/spot/SpotMarketsDropdown.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from 'react'; import styled from 'styled-components'; import { SMALL_USD_DECIMALS } from '@/constants/numbers'; +import { SOLANA_MAINNET_ID } from '@/constants/solana'; import { layoutMixins } from '@/styles/layoutMixins'; import { popoverMixins } from '@/styles/popoverMixins'; @@ -60,9 +61,9 @@ export const SpotMarketsDropdown = ({ getCellValue: (row) => row.name, label: 'Name', renderCell: ({ symbol, logoUrl, tokenAddress }) => ( -
+
- <$AssetIcon logoUrl={logoUrl} symbol={symbol} /> + <$AssetIcon logoUrl={logoUrl} symbol={symbol} chainId={SOLANA_MAINNET_ID} />

{symbol}

@@ -222,7 +223,7 @@ const $Popover = styled(Popover)` ) - var(--restriction-warning-currentHeight) ); - width: var(--marketsDropdown-openWidth); + width: var(--spot-marketsDropdown-openWidth); max-width: 100vw; @@ -270,7 +271,8 @@ const $Table = styled(Table)` ` as typeof Table; const $AssetIcon = styled(AssetIcon)` - --asset-icon-size: 1.5em; + --asset-icon-size: 2rem; + --asset-icon-chain-icon-borderColor: var(--color-layer-2); `; const $FavoriteStatus = styled(Icon)` diff --git a/src/pages/spot/SpotTradeForm.tsx b/src/pages/spot/SpotTradeForm.tsx index 688a1ccf70..4c0461e413 100644 --- a/src/pages/spot/SpotTradeForm.tsx +++ b/src/pages/spot/SpotTradeForm.tsx @@ -3,7 +3,8 @@ import { useCallback, useMemo, useRef } from 'react'; import { SpotBuyInputType, SpotSellInputType, SpotSide } from '@/bonsai/forms/spot'; import { BonsaiCore } from '@/bonsai/ontology'; -import { ButtonAction, ButtonState } from '@/constants/buttons'; +import { SpotWalletStatus } from '@/constants/account'; +import { ButtonAction, ButtonSize } from '@/constants/buttons'; import { STRING_KEYS } from '@/constants/localization'; import { useSpotForm } from '@/hooks/useSpotForm'; @@ -13,6 +14,7 @@ import { Button } from '@/components/Button'; import { Icon, IconName } from '@/components/Icon'; import { LoadingSpace } from '@/components/Loading/LoadingSpinner'; import { ValidationAlertMessage } from '@/components/ValidationAlert'; +import { OnboardingTriggerButton } from '@/views/dialogs/OnboardingTriggerButton'; import { useAppDispatch, useAppSelector } from '@/state/appTypes'; import { setSpotQuickOptions } from '@/state/appUiConfigs'; @@ -91,14 +93,14 @@ export const SpotTradeForm = () => { return ( { form.actions.setSide(v as SpotSide); }} sharedContent={
- {!form.inputData.isReady ? ( + {!form.inputData.isAsyncDataReady ? ( ) : ( <> @@ -125,6 +127,7 @@ export const SpotTradeForm = () => { : form.state.sellInputType } onInputTypeChange={form.handleInputTypeChange} + onBalanceClick={(value) => form.actions.setSize(value)} side={form.state.side} tokenAmount={form.summary.amounts?.token ?? 0} tokenSymbol={tokenMetadata?.symbol ?? ''} @@ -143,25 +146,26 @@ export const SpotTradeForm = () => { form.primaryAlert.resources.text?.fallback != null) && ( )} - + {form.inputData.walletStatus === SpotWalletStatus.Disconnected ? ( + + ) : ( + + )} )}
diff --git a/src/state/accountCalculators.ts b/src/state/accountCalculators.ts index c36ba0b45d..013a38ec90 100644 --- a/src/state/accountCalculators.ts +++ b/src/state/accountCalculators.ts @@ -1,8 +1,8 @@ import { BonsaiCore } from '@/bonsai/ontology'; import { VaultFormAccountData } from '@/bonsai/public-calculators/vaultFormValidation'; -import { OnboardingState, OnboardingSteps } from '@/constants/account'; -import { ConnectorType } from '@/constants/wallets'; +import { OnboardingState, OnboardingSteps, SpotWalletStatus } from '@/constants/account'; +import { ConnectorType, WalletNetworkType } from '@/constants/wallets'; import { getDisplayChooseWallet, @@ -142,3 +142,19 @@ export const selectSubaccountStateForVaults = createAppSelector( canViewAccount, }) ); + +export const calculateSpotWalletStatus = createAppSelector( + [getOnboardingState, getSourceAccount], + (onboardingState, sourceAccount): SpotWalletStatus => { + const isWalletConnected = onboardingState === OnboardingState.AccountConnected; + const canDeriveSolanaWallet = sourceAccount.chain !== WalletNetworkType.Cosmos; + + if (!isWalletConnected) { + return SpotWalletStatus.Disconnected; + } + if (!canDeriveSolanaWallet) { + return SpotWalletStatus.Unsupported; + } + return SpotWalletStatus.Connected; + } +); diff --git a/src/state/spotFormSelectors.ts b/src/state/spotFormSelectors.ts index e06b29f242..b670e05eec 100644 --- a/src/state/spotFormSelectors.ts +++ b/src/state/spotFormSelectors.ts @@ -2,10 +2,13 @@ import { SpotFormInputData } from '@/bonsai/forms/spot'; import { BonsaiCore, BonsaiForms } from '@/bonsai/ontology'; import { LAMPORTS_PER_SOL } from '@solana/web3.js'; +import { SpotWalletStatus } from '@/constants/account'; + import { SpotApiTradeRoute } from '@/clients/spotApi'; import { isPresent } from '@/lib/typeUtils'; import { type RootState } from './_store'; +import { calculateSpotWalletStatus } from './accountCalculators'; import { getUserSolanaWalletAddress } from './accountInfoSelectors'; import { createAppSelector } from './appTypes'; import { getCurrentSpotToken } from './spot'; @@ -24,6 +27,7 @@ export const getSpotFormInputData = createAppSelector( BonsaiCore.spot.walletPositions.loading, getCurrentSpotToken, getUserSolanaWalletAddress, + calculateSpotWalletStatus, ], ( solPriceUsd, @@ -35,7 +39,8 @@ export const getSpotFormInputData = createAppSelector( tokenPriceStatus, walletPositionsStatus, tokenMint, - solanaAddress + solanaAddress, + walletStatus ): SpotFormInputData => { const userSolBalance = isPresent(walletPositions?.solBalance) ? walletPositions.solBalance / LAMPORTS_PER_SOL @@ -45,12 +50,15 @@ export const getSpotFormInputData = createAppSelector( (position) => position.mint === tokenMint )?.amount; - const isAsyncDataReady = [ - solPriceStatus, - tokenMetadataStatus, - tokenPriceStatus, - walletPositionsStatus, - ].every((status) => status === 'success'); + const isMarketDataReady = [solPriceStatus, tokenMetadataStatus, tokenPriceStatus].every( + (status) => status === 'success' + ); + + // For connected users, also wait for wallet positions (for balances) + const isAsyncDataReady = + walletStatus === SpotWalletStatus.Connected + ? isMarketDataReady && walletPositionsStatus === 'success' + : isMarketDataReady; const isRestReady = isPresent(tokenMint) && isPresent(solanaAddress); @@ -67,6 +75,7 @@ export const getSpotFormInputData = createAppSelector( isReady: isRestReady && isAsyncDataReady, isAsyncDataReady, isRestReady, + walletStatus, }; } ); diff --git a/src/styles/constants.css b/src/styles/constants.css index 1d8b928ba8..509ccb8328 100644 --- a/src/styles/constants.css +++ b/src/styles/constants.css @@ -49,6 +49,7 @@ /* Market Selector constants */ --marketsDropdown-openWidth: 55rem; + --spot-marketsDropdown-openWidth: 40rem; /* Form constants */ --form-input-gap: 0.625rem; diff --git a/src/views/dialogs/TransferDialogs/DepositAddressCard.tsx b/src/views/dialogs/TransferDialogs/DepositAddressCard.tsx new file mode 100644 index 0000000000..a4fdddcef0 --- /dev/null +++ b/src/views/dialogs/TransferDialogs/DepositAddressCard.tsx @@ -0,0 +1,118 @@ +import { useMemo } from 'react'; + +import styled from 'styled-components'; + +import { STRING_KEYS } from '@/constants/localization'; + +import { useCopyValue } from '@/hooks/useCopyValue'; +import { useStringGetter } from '@/hooks/useStringGetter'; + +import { Icon, IconName } from '@/components/Icon'; +import { LoadingSpace } from '@/components/Loading/LoadingSpinner'; +import { QrCode } from '@/components/QrCode'; + +type DepositAddressCardProps = { + address: string | undefined; + chainIcon: string | undefined; + chainName: string | undefined; + isLoading?: boolean; + error?: boolean; + errorMessage?: string; +}; + +export const DepositAddressCard = ({ + address, + chainIcon, + chainName, + isLoading = false, + error = false, + errorMessage, +}: DepositAddressCardProps) => { + const stringGetter = useStringGetter(); + const { copied, copy } = useCopyValue({ value: address }); + + /** + * @description Splits the address into 3 parts: first 5 chars, middle part, last 5 chars to render in the UI + */ + const addressRepresentation = useMemo(() => { + if (address == null || address.trim().length === 0) { + return undefined; + } + + const firstPart = address.slice(0, 5); + const middlePart = address.slice(5, -5); + const lastPart = address.slice(-5); + + return { + firstPart, + middlePart, + lastPart, + }; + }, [address]); + + if (isLoading) { + return ( + <$AddressCard> +
+ +
+ + ); + } + + if (error) { + return ( + <$AddressCard> +
+ + + {errorMessage ?? stringGetter({ key: STRING_KEYS.SOMETHING_WENT_WRONG })} + +
+ + ); + } + + return ( + <$AddressCard onClick={copy} tabIndex={0} role="button"> +
+ {chainName} + {addressRepresentation && address ? ( +
+
+ {addressRepresentation.firstPart} + {addressRepresentation.middlePart} + + {addressRepresentation.lastPart} + {copied ? ( + + ) : ( + + )} + +
+
+ ) : null} +
+ +
+ {address && } +
+ + ); +}; + +const $AddressCard = styled.div` + display: flex; + flex-direction: row; + border-radius: 1rem; + border: 1px solid var(--border-color); + padding: 1rem; + gap: 1rem; +`; diff --git a/src/views/dialogs/TransferDialogs/DepositAddressDialog.tsx b/src/views/dialogs/TransferDialogs/DepositAddressDialog.tsx index 6db841b2ec..6643e443ba 100644 --- a/src/views/dialogs/TransferDialogs/DepositAddressDialog.tsx +++ b/src/views/dialogs/TransferDialogs/DepositAddressDialog.tsx @@ -12,8 +12,8 @@ import { SOLANA_MAINNET_ID } from '@/constants/solana'; import { useAccounts } from '@/hooks/useAccounts'; import { useBreakpoints } from '@/hooks/useBreakpoints'; -import { useCopyValue } from '@/hooks/useCopyValue'; import { useDepositAddress } from '@/hooks/useDepositAddress'; +import { useEnableSpot } from '@/hooks/useEnableSpot'; import { useLocaleSeparators } from '@/hooks/useLocaleSeparators'; import { useSimpleUiEnabled } from '@/hooks/useSimpleUiEnabled'; import { useStringGetter } from '@/hooks/useStringGetter'; @@ -26,12 +26,10 @@ import { layoutMixins } from '@/styles/layoutMixins'; import { AlertMessage } from '@/components/AlertMessage'; import { Dialog, DialogPlacement } from '@/components/Dialog'; import { Icon, IconName } from '@/components/Icon'; -import { LoadingSpace } from '@/components/Loading/LoadingSpinner'; import { formatNumberOutput, OutputType } from '@/components/Output'; -import { QrCode } from '@/components/QrCode'; import { SelectItem, SelectMenu, SelectMenuTrigger } from '@/components/SelectMenu'; -import { TabGroup } from '@/components/TabGroup'; import { WithLabel } from '@/components/WithLabel'; +import { SpotTabItem, SpotTabs } from '@/pages/spot/SpotTabs'; import { useAppSelector } from '@/state/appTypes'; import { getSelectedLocale } from '@/state/localizationSelectors'; @@ -39,6 +37,9 @@ import { getSelectedLocale } from '@/state/localizationSelectors'; import { track } from '@/lib/analytics/analytics'; import { calc } from '@/lib/do'; +import { DepositAddressCard } from './DepositAddressCard'; +import { SpotDepositForm } from './DepositDialog2/SpotDepositForm'; + type DepositTab = 'spot' | 'perpetuals'; const MIN_DEPOSIT = 20; @@ -52,8 +53,9 @@ export const DepositAddressDialog = ({ setIsOpen }: DialogProps { + const perpsDepositAddress: string | undefined = useMemo(() => { if (depositAddresses == null) { return undefined; } - if (selectedTab === 'perpetuals') { - if (selectedChain === avalanche.id.toString()) { - return depositAddresses.avalancheAddress; - } - - if (selectedChain === SOLANA_MAINNET_ID) { - return depositAddresses.svmAddress; - } + if (selectedChain === avalanche.id.toString()) { + return depositAddresses.avalancheAddress; + } - return depositAddresses.evmAddress; + if (selectedChain === SOLANA_MAINNET_ID) { + return depositAddresses.svmAddress; } - return undefined; // TODO(spot): Add spot deposit address - }, [depositAddresses, selectedChain, selectedTab]); + return depositAddresses.evmAddress; + }, [depositAddresses, selectedChain]); useEffect(() => { if (chains[0] == null) { @@ -118,25 +116,6 @@ export const DepositAddressDialog = ({ setIsOpen }: DialogProps { - if (depositAddress == null || depositAddress.trim().length === 0) { - return undefined; - } - - const firstPart = depositAddress.slice(0, 5); - const middlePart = depositAddress.slice(5, -5); - const lastPart = depositAddress.slice(-5); - - return { - firstPart, - middlePart, - lastPart, - }; - }, [depositAddress]); - const { decimal: decimalSeparator, group: groupSeparator } = useLocaleSeparators(); const selectedLocale = useAppSelector(getSelectedLocale); @@ -185,54 +164,6 @@ export const DepositAddressDialog = ({ setIsOpen }: DialogProps -
- -
- - ) : failedToFetchDepositAddresses ? ( - <$AddressCard> -
- - - {stringGetter({ key: STRING_KEYS.SOMETHING_WENT_WRONG })} - -
- - ) : ( - <$AddressCard onClick={copy} tabIndex={0} role="button"> -
- {CHAIN_INFO[selectedChain]?.name} - {addressRepresentation && depositAddress ? ( -
-
- {addressRepresentation.firstPart} - {addressRepresentation.middlePart} - {addressRepresentation.lastPart} -
- {copied ? ( - - ) : ( - - )} -
- ) : null} -
- -
- {depositAddress && } -
- - ); - const getAcceptedAssets = (id: string | number) => { if (id === SOLANA_MAINNET_ID || id === avalanche.id) { return stringGetter({ key: STRING_KEYS.ACCEPTS_ONLY_USDC }); @@ -244,6 +175,90 @@ export const DepositAddressDialog = ({ setIsOpen }: DialogProps { + setSelectedTab(newTab === 'perps' ? 'perpetuals' : 'spot'); + }; + + const perpetualsContent = ( +
+ + <$WithLabel label={stringGetter({ key: STRING_KEYS.NETWORK })}> +
{CHAIN_INFO[selectedChain]?.name}
+ + + + } + position="popper" + side="bottom" + sideOffset={8} + contentCss={{ + '--popover-backgroundColor': 'var(--color-layer-2)', + }} + > + {chains.map((id) => ( + <$SelectItem + key={id} + value={id.toString()} + withIcon={false} + label={ +
+
+
{CHAIN_INFO[id]?.name}
+
{getAcceptedAssets(id)}
+
+ {CHAIN_INFO[id]?.name} +
+ } + /> + ))} +
+ + + + {warningMessage} +
+ ); + + const tabs: SpotTabItem[] = [ + { + value: 'perps', + label: 'Perpetuals', + content: perpetualsContent, + }, + { + value: 'spot', + label: 'Spot', + content: , + }, + ]; + return ( <$Dialog isOpen @@ -261,75 +276,13 @@ export const DepositAddressDialog = ({ setIsOpen }: DialogProps -
- {false && ( // TODO(spot): Remove false conditional when spot is supported -
- -
- )} - - - <$WithLabel label={stringGetter({ key: STRING_KEYS.NETWORK })}> -
{CHAIN_INFO[selectedChain]?.name}
- - - - } - position="popper" - side="bottom" - sideOffset={8} - contentCss={{ - '--popover-backgroundColor': 'var(--color-layer-2)', - }} - > - {chains.map((id) => ( - <$SelectItem - key={id} - value={id.toString()} - withIcon={false} - label={ -
-
-
{CHAIN_INFO[id]?.name}
-
{getAcceptedAssets(id)}
-
- {CHAIN_INFO[id]?.name} -
- } - /> - ))} -
- - {addressCard} - - {warningMessage} +
+ handleTabChange(v as 'perps' | 'spot')} + hideTabs={!isSpotEnabled || !solanaAddress} + items={tabs} + />
); @@ -347,15 +300,6 @@ const $Dialog = styled(Dialog)` } `; -const $AddressCard = styled.div` - display: flex; - flex-direction: row; - border-radius: 1rem; - border: 1px solid var(--border-color); - padding: 1rem; - gap: 1rem; -`; - const $WithLabel = styled(WithLabel)` ${formMixins.inputLabel} border-radius: 0; diff --git a/src/views/dialogs/TransferDialogs/DepositDialog2/DepositDialog2.tsx b/src/views/dialogs/TransferDialogs/DepositDialog2/DepositDialog2.tsx index 56f0793f56..af14dcb5de 100644 --- a/src/views/dialogs/TransferDialogs/DepositDialog2/DepositDialog2.tsx +++ b/src/views/dialogs/TransferDialogs/DepositDialog2/DepositDialog2.tsx @@ -12,16 +12,21 @@ import { ConnectorType, WalletNetworkType } from '@/constants/wallets'; import { useAccounts } from '@/hooks/useAccounts'; import { useBreakpoints } from '@/hooks/useBreakpoints'; +import { useEnableSpot } from '@/hooks/useEnableSpot'; import { useStringGetter } from '@/hooks/useStringGetter'; +import breakpoints from '@/styles/breakpoints'; + import { Dialog, DialogPlacement } from '@/components/Dialog'; import { LoadingSpace } from '@/components/Loading/LoadingSpinner'; +import { SpotTabItem, SpotTabs } from '@/pages/spot/SpotTabs'; import { useAppDispatch } from '@/state/appTypes'; import { openDialog } from '@/state/dialogs'; import { SourceAccount } from '@/state/wallet'; import { DepositFormContent, DepositFormState } from './DepositForm/DepositFormContainer'; +import { SpotDepositForm } from './SpotDepositForm'; import { useDepositTokenBalances } from './queries'; function getDefaultToken( @@ -63,16 +68,23 @@ function getDefaultToken( export const DepositDialog2 = ({ setIsOpen }: DialogProps) => { const dispatch = useAppDispatch(); - const { sourceAccount } = useAccounts(); + const { sourceAccount, solanaAddress } = useAccounts(); const { isLoading: isLoadingBalances, withBalances } = useDepositTokenBalances(); const highestBalance = withBalances.at(0); const { isMobile } = useBreakpoints(); const stringGetter = useStringGetter(); + const isSpotEnabled = useEnableSpot(); + const [currentDepositType, setCurrentDepositType] = useState<'perps' | 'spot'>('perps'); const [formState, setFormState] = useState('form'); const tokenSelectRef = useRef(null); + const handleTabChange = (newTab: 'perps' | 'spot') => { + setCurrentDepositType(newTab); + setFormState('form'); + }; + const dialogTitle = ( { form: stringGetter({ key: STRING_KEYS.DEPOSIT }), @@ -101,18 +113,11 @@ export const DepositDialog2 = ({ setIsOpen }: DialogProps) } }, [sourceAccount, dispatch, setIsOpen]); - return ( - <$Dialog - isOpen - preventCloseOnOverlayClick - withAnimation - hasHeaderBorder - setIsOpen={setIsOpen} - onBack={formState === 'form' ? undefined : onBack} - title={dialogTitle} - placement={isMobile ? DialogPlacement.FullScreen : DialogPlacement.Default} - > - {isLoadingBalances ? ( + const tabs: SpotTabItem[] = [ + { + value: 'perps', + label: 'Perpetuals', + content: isLoadingBalances ? (
@@ -125,16 +130,47 @@ export const DepositDialog2 = ({ setIsOpen }: DialogProps) tokenSelectRef={tokenSelectRef} onShowForm={onShowForm} /> - )} + ), + }, + { + value: 'spot', + label: 'Spot', + content: , + }, + ]; + + return ( + <$Dialog + isOpen + preventCloseOnOverlayClick + withAnimation + setIsOpen={setIsOpen} + onBack={formState === 'form' || currentDepositType === 'spot' ? undefined : onBack} + title={dialogTitle} + placement={isMobile ? DialogPlacement.FullScreen : DialogPlacement.Default} + > +
+ handleTabChange(v as 'perps' | 'spot')} + hideTabs={formState !== 'form' || !isSpotEnabled || !solanaAddress} + items={tabs} + /> +
); }; const $Dialog = styled(Dialog)` - --dialog-content-paddingTop: 0; - --dialog-content-paddingRight: 0; - --dialog-content-paddingBottom: 0; - --dialog-content-paddingLeft: 0; - --asset-icon-chain-icon-borderColor: var(--dialog-backgroundColor); + + @media ${breakpoints.notMobile} { + max-width: 26.25rem; + --dialog-content-paddingLeft: 1.25rem; + --dialog-content-paddingRight: 1.25rem; + --dialog-content-paddingBottom: 1.25rem; + --dialog-paddingX: 1.25rem; + --dialog-header-paddingTop: 1.25rem; + --dialog-header-paddingBottom: 1.25rem; + } `; diff --git a/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/DepositForm.tsx b/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/DepositForm.tsx index f98f2ec655..bd471a0e65 100644 --- a/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/DepositForm.tsx +++ b/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/DepositForm.tsx @@ -446,7 +446,7 @@ export const DepositForm = ({ ); return ( -
+
{depositContent}
{depositContentBottom}
diff --git a/src/views/dialogs/TransferDialogs/DepositDialog2/SpotDepositForm.tsx b/src/views/dialogs/TransferDialogs/DepositDialog2/SpotDepositForm.tsx new file mode 100644 index 0000000000..163f2bead6 --- /dev/null +++ b/src/views/dialogs/TransferDialogs/DepositDialog2/SpotDepositForm.tsx @@ -0,0 +1,28 @@ +import { AlertType } from '@/constants/alerts'; +import { CHAIN_INFO } from '@/constants/chains'; +import { SOLANA_MAINNET_ID } from '@/constants/solana'; + +import { useAccounts } from '@/hooks/useAccounts'; + +import { AlertMessage } from '@/components/AlertMessage'; + +import { DepositAddressCard } from '../DepositAddressCard'; + +// TODO: spot localization + +export const SpotDepositForm = () => { + const { solanaAddress } = useAccounts(); + + return ( +
+ + + Only send SOL on Solana. Assets on other networks will be lost. + +
+ ); +}; diff --git a/src/views/dialogs/TransferDialogs/WithdrawDialog2/WithdrawDialog2.tsx b/src/views/dialogs/TransferDialogs/WithdrawDialog2/WithdrawDialog2.tsx index 9da39498e5..c3c46adc9e 100644 --- a/src/views/dialogs/TransferDialogs/WithdrawDialog2/WithdrawDialog2.tsx +++ b/src/views/dialogs/TransferDialogs/WithdrawDialog2/WithdrawDialog2.tsx @@ -31,7 +31,7 @@ import { WithdrawForm } from './WithdrawForm'; import { WithdrawStatus } from './WithdrawStatus'; export const WithdrawDialog2 = ({ setIsOpen }: DialogProps) => { - const { dydxAddress, sourceAccount } = useAccounts(); + const { dydxAddress, sourceAccount, solanaAddress } = useAccounts(); const isPrivy = sourceAccount.walletInfo?.name === WalletType.Privy; const isTurnkey = sourceAccount.walletInfo?.name === WalletType.Turnkey; const [destinationAddress, setDestinationAddress] = useState( @@ -176,7 +176,7 @@ export const WithdrawDialog2 = ({ setIsOpen }: DialogProps) handleTabChange(v as 'perps' | 'spot')} - hideTabs={formState === 'chain-select' || !isSpotEnabled} + hideTabs={formState === 'chain-select' || !isSpotEnabled || !solanaAddress} items={tabs} />
From 1d525f6b672f6ba5518a07b67762527966fccc18 Mon Sep 17 00:00:00 2001 From: Paata Kardava Date: Thu, 11 Dec 2025 01:28:28 +0400 Subject: [PATCH 11/13] fix: deposit dialog styling (#2016) --- .../TransferDialogs/DepositAddressDialog.tsx | 14 +-- .../DepositDialog2/DepositDialog2.tsx | 46 ++++--- .../DepositForm/DepositForm.tsx | 1 - .../DepositForm/DepositFormContainer.tsx | 114 ++++++++---------- 4 files changed, 83 insertions(+), 92 deletions(-) diff --git a/src/views/dialogs/TransferDialogs/DepositAddressDialog.tsx b/src/views/dialogs/TransferDialogs/DepositAddressDialog.tsx index 6643e443ba..75614ee132 100644 --- a/src/views/dialogs/TransferDialogs/DepositAddressDialog.tsx +++ b/src/views/dialogs/TransferDialogs/DepositAddressDialog.tsx @@ -275,8 +275,9 @@ export const DepositAddressDialog = ({ setIsOpen }: DialogProps } placement={isMobile ? DialogPlacement.FullScreen : DialogPlacement.Default} + hasHeaderBorder > -
+
handleTabChange(v as 'perps' | 'spot')} @@ -289,14 +290,13 @@ export const DepositAddressDialog = ({ setIsOpen }: DialogProps) const [currentDepositType, setCurrentDepositType] = useState<'perps' | 'spot'>('perps'); const [formState, setFormState] = useState('form'); + const [currentPerpsDeposit, setCurrentPerpsDeposit] = useState<{ + txHash: string; + chainId: string; + }>(); const tokenSelectRef = useRef(null); const handleTabChange = (newTab: 'perps' | 'spot') => { @@ -126,7 +129,7 @@ export const DepositDialog2 = ({ setIsOpen }: DialogProps) defaultToken={getDefaultToken(sourceAccount, highestBalance)} formState={formState} setFormState={setFormState} - setIsOpen={setIsOpen} + onDeposit={setCurrentPerpsDeposit} tokenSelectRef={tokenSelectRef} onShowForm={onShowForm} /> @@ -148,29 +151,32 @@ export const DepositDialog2 = ({ setIsOpen }: DialogProps) onBack={formState === 'form' || currentDepositType === 'spot' ? undefined : onBack} title={dialogTitle} placement={isMobile ? DialogPlacement.FullScreen : DialogPlacement.Default} + hasHeaderBorder > -
- handleTabChange(v as 'perps' | 'spot')} - hideTabs={formState !== 'form' || !isSpotEnabled || !solanaAddress} - items={tabs} + {currentPerpsDeposit ? ( + setIsOpen(false)} + txHash={currentPerpsDeposit.txHash} + chainId={currentPerpsDeposit.chainId} /> -
+ ) : ( +
+ handleTabChange(v as 'perps' | 'spot')} + hideTabs={formState !== 'form' || !isSpotEnabled || !solanaAddress} + items={tabs} + /> +
+ )} ); }; const $Dialog = styled(Dialog)` --asset-icon-chain-icon-borderColor: var(--dialog-backgroundColor); - - @media ${breakpoints.notMobile} { - max-width: 26.25rem; - --dialog-content-paddingLeft: 1.25rem; - --dialog-content-paddingRight: 1.25rem; - --dialog-content-paddingBottom: 1.25rem; - --dialog-paddingX: 1.25rem; - --dialog-header-paddingTop: 1.25rem; - --dialog-header-paddingBottom: 1.25rem; - } + --dialog-content-paddingTop: 0; + --dialog-content-paddingRight: 0; + --dialog-content-paddingBottom: 0; + --dialog-content-paddingLeft: 0; `; diff --git a/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/DepositForm.tsx b/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/DepositForm.tsx index bd471a0e65..56ed6887f6 100644 --- a/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/DepositForm.tsx +++ b/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/DepositForm.tsx @@ -384,7 +384,6 @@ export const DepositForm = ({ />
void; setFormState: Dispatch>; - setIsOpen: (isOpen: boolean) => void; tokenSelectRef: RefObject; + onDeposit: (args: { txHash: string; chainId: string }) => void; }) => { const dispatch = useAppDispatch(); const { dydxAddress } = useAccounts(); @@ -36,81 +35,68 @@ export const DepositFormContent = ({ const [amount, setAmount] = useState(''); const [token, setToken] = useState(defaultToken); - const [currentDeposit, setCurrentDeposit] = useState<{ txHash: string; chainId: string }>(); const onShowQrDeposit = () => { setFormState('qr-deposit'); tokenSelectRef.current?.scroll({ top: 0 }); }; - const onDeposit = (deposit: Deposit) => { + const handleDeposit = (deposit: Deposit) => { if (!dydxAddress) return; - setCurrentDeposit({ txHash: deposit.txHash, chainId: deposit.chainId }); + onDeposit({ txHash: deposit.txHash, chainId: deposit.chainId }); dispatch(addDeposit({ deposit, dydxAddress })); }; return ( - <> - {currentDeposit && ( - setIsOpen(false)} - txHash={currentDeposit.txHash} - chainId={currentDeposit.chainId} - /> - )} - - {!currentDeposit && ( -
-
-
- setFormState('token-select')} - /> -
+
+
+
+ setFormState('token-select')} + /> +
-
- -
+
+ +
-
- -
-
+
+
- )} - +
+
); }; From e1c2d344c38934fa705561a0c60e14d3e6c8a784 Mon Sep 17 00:00:00 2001 From: Jared Vu Date: Wed, 10 Dec 2025 13:39:57 -0800 Subject: [PATCH 12/13] fix: use instant deposit text when depositing from near instant chains (#2015) --- src/constants/chains.ts | 9 +++++++++ .../DepositDialog2/DepositForm/DepositStatus.tsx | 12 +++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/constants/chains.ts b/src/constants/chains.ts index 64ae324b44..842e1186a1 100644 --- a/src/constants/chains.ts +++ b/src/constants/chains.ts @@ -90,4 +90,13 @@ export function isEvmDepositChainId(chainId: string) { return EVM_DEPOSIT_CHAINS.map((chain) => String(chain.id)).includes(chainId); } +export const NEAR_INSTANT_DEPOSIT_CHAINS = [ + SOLANA_MAINNET_ID, + CosmosChainId.Noble, + CosmosChainId.Osmosis, + CosmosChainId.Neutron, + polygon.id.toString(), + avalanche.id.toString(), +]; + export const DYDX_DEPOSIT_CHAIN = 'dydx-mainnet-1'; diff --git a/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/DepositStatus.tsx b/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/DepositStatus.tsx index 726b047d15..e0bdc0cd48 100644 --- a/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/DepositStatus.tsx +++ b/src/views/dialogs/TransferDialogs/DepositDialog2/DepositForm/DepositStatus.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { ButtonAction } from '@/constants/buttons'; +import { NEAR_INSTANT_DEPOSIT_CHAINS } from '@/constants/chains'; import { STRING_KEYS } from '@/constants/localization'; import { useAppSelectorWithArgs } from '@/hooks/useParameterizedSelector'; @@ -32,10 +33,15 @@ export const DepositStatus = ({ txHash, chainId, onClose }: DepositStatusProps) const depositSuccess = deposit?.status === 'success'; const statusDescription = useMemo(() => { - if (depositSuccess) return stringGetter({ key: STRING_KEYS.YOUR_FUNDS_AVAILABLE_FOR_TRADING }); - - if (deposit?.isInstantDeposit) + if (depositSuccess) { + return stringGetter({ key: STRING_KEYS.YOUR_FUNDS_AVAILABLE_FOR_TRADING }); + } + if ( + Boolean(deposit?.isInstantDeposit) || + NEAR_INSTANT_DEPOSIT_CHAINS.includes(deposit?.chainId ?? '') + ) { return stringGetter({ key: STRING_KEYS.YOUR_FUNDS_AVAILABLE_SOON }); + } return stringGetter({ key: STRING_KEYS.YOU_MAY_CLOSE_WINDOW }); }, [deposit, depositSuccess, stringGetter]); From 6f065fb4db200a0dced4bfcce3d3c3e3d6a36670 Mon Sep 17 00:00:00 2001 From: dwjanus Date: Wed, 10 Dec 2025 17:11:41 -0500 Subject: [PATCH 13/13] account menu --- src/views/menus/AccountMenu/AccountMenu.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/views/menus/AccountMenu/AccountMenu.tsx b/src/views/menus/AccountMenu/AccountMenu.tsx index 8d79b1722e..906e208a35 100644 --- a/src/views/menus/AccountMenu/AccountMenu.tsx +++ b/src/views/menus/AccountMenu/AccountMenu.tsx @@ -7,7 +7,7 @@ import styled, { css } from 'styled-components'; import tw from 'twin.macro'; import { AMOUNT_RESERVED_FOR_GAS_USDC, OnboardingState } from '@/constants/account'; -import { ButtonAction, ButtonShape, ButtonSize } from '@/constants/buttons'; +import { ButtonAction, ButtonShape, ButtonSize, ButtonType } from '@/constants/buttons'; import { DialogTypes } from '@/constants/dialogs'; import { STRING_KEYS } from '@/constants/localization'; import { isDev } from '@/constants/networks'; @@ -58,8 +58,6 @@ import { SpotActions } from './SpotActions'; import { SubaccountActions } from './SubaccountActions'; import { WalletActions } from './WalletActions'; -// TODO: spot localization - export const AccountMenu = () => { const stringGetter = useStringGetter(); const { isTablet } = useBreakpoints();