diff --git a/app/api/pairs/route.ts b/app/api/pairs/route.ts new file mode 100644 index 00000000..8c99a76e --- /dev/null +++ b/app/api/pairs/route.ts @@ -0,0 +1 @@ +export { GET } from '@/shared/api/server/summary/pairs'; diff --git a/app/app.tsx b/app/app.tsx index 2a0b6dbe..a4890226 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -8,6 +8,8 @@ import { TooltipProvider } from '@penumbra-zone/ui/Tooltip'; import { Header, SyncBar } from '@/widgets/header'; import { queryClient } from '@/shared/const/queryClient'; import { connectionStore } from '@/shared/model/connection'; +import { recentPairsStore } from '@/pages/trade/ui/pair-selector/store'; +import { starStore } from '@/features/star-pair'; // Used so that observer() won't subscribe to any observables used in an SSR environment // and no garbage collection problems are introduced. @@ -16,6 +18,8 @@ enableStaticRendering(typeof window === 'undefined'); export const App = observer(({ children }: { children: ReactNode }) => { useEffect(() => { connectionStore.setup(); + recentPairsStore.setup(); + starStore.setup(); }, []); return ( diff --git a/global.d.ts b/global.d.ts index fa9154c3..8fb49c53 100644 --- a/global.d.ts +++ b/global.d.ts @@ -2,3 +2,9 @@ declare module '*.css' { const content: Record; export default content; } + +declare module '*.svg' { + import { FC, SVGAttributes } from 'react'; + const value: FC>; + export default value; +} diff --git a/package.json b/package.json index 935d3a95..31be098e 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@penumbra-zone/protobuf": "^6.3.0", "@penumbra-zone/transport-dom": "^7.5.0", "@penumbra-zone/types": "^26.3.0", - "@penumbra-zone/ui": "^13.5.0", + "@penumbra-zone/ui": "^13.6.0", "@penumbra-zone/wasm": "^34.0.0", "@radix-ui/react-icons": "^1.3.2", "@rehooks/component-size": "^1.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74545185..fb7c8b0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,8 +66,8 @@ importers: specifier: ^26.3.0 version: 26.3.0(@bufbuild/protobuf@1.10.0)(@penumbra-zone/bech32m@10.0.0(@penumbra-zone/protobuf@6.3.0(@bufbuild/protobuf@1.10.0)))(@penumbra-zone/getters@20.0.0(@bufbuild/protobuf@1.10.0)(@penumbra-zone/bech32m@10.0.0(@penumbra-zone/protobuf@6.3.0(@bufbuild/protobuf@1.10.0)))(@penumbra-zone/protobuf@6.3.0(@bufbuild/protobuf@1.10.0)))(@penumbra-zone/protobuf@6.3.0(@bufbuild/protobuf@1.10.0)) '@penumbra-zone/ui': - specifier: ^13.5.0 - version: 13.5.0(@bufbuild/protobuf@1.10.0)(@types/react-dom@18.3.2)(@types/react@18.3.14) + specifier: ^13.6.0 + version: 13.6.0(@bufbuild/protobuf@1.10.0)(@types/react-dom@18.3.2)(@types/react@18.3.14) '@penumbra-zone/wasm': specifier: ^34.0.0 version: 34.0.0(@bufbuild/protobuf@1.10.0)(@penumbra-zone/bech32m@10.0.0(@penumbra-zone/protobuf@6.3.0(@bufbuild/protobuf@1.10.0)))(@penumbra-zone/protobuf@6.3.0(@bufbuild/protobuf@1.10.0))(@penumbra-zone/types@26.3.0(@bufbuild/protobuf@1.10.0)(@penumbra-zone/bech32m@10.0.0(@penumbra-zone/protobuf@6.3.0(@bufbuild/protobuf@1.10.0)))(@penumbra-zone/getters@20.0.0(@bufbuild/protobuf@1.10.0)(@penumbra-zone/bech32m@10.0.0(@penumbra-zone/protobuf@6.3.0(@bufbuild/protobuf@1.10.0)))(@penumbra-zone/protobuf@6.3.0(@bufbuild/protobuf@1.10.0)))(@penumbra-zone/protobuf@6.3.0(@bufbuild/protobuf@1.10.0))) @@ -1770,8 +1770,8 @@ packages: '@penumbra-zone/getters': 20.0.0 '@penumbra-zone/protobuf': 6.3.0 - '@penumbra-zone/ui@13.5.0': - resolution: {integrity: sha512-xeYpmEfB5HDvNBAV0EPI8bCeEQ0ZZmkDGkTc9kLq93J4nkI0VH+wmFJkxbLoPsaS2BcMpodAIG2+noAGU4ouSw==} + '@penumbra-zone/ui@13.6.0': + resolution: {integrity: sha512-+Rz5gOzH7wWGwUW9C0QixsTdG4NIrftrJhFgPlIRbe+o1l/MhhUcuv3WdUmLCMWH1WFBBbcnfoibqIW3qMt2AA==} '@penumbra-zone/wasm@34.0.0': resolution: {integrity: sha512-dHevSVkfUcW5uq/HCq/ShyP5Ta4/I1x4Fvz1BPuOT68jGECseamRS6RH4MiQrQPxKoUuxvfjTH7E/pE5M6+cOA==} @@ -7344,7 +7344,7 @@ snapshots: lodash: 4.17.21 zod: 3.23.8 - '@penumbra-zone/ui@13.5.0(@bufbuild/protobuf@1.10.0)(@types/react-dom@18.3.2)(@types/react@18.3.14)': + '@penumbra-zone/ui@13.6.0(@bufbuild/protobuf@1.10.0)(@types/react-dom@18.3.2)(@types/react@18.3.14)': dependencies: '@penumbra-zone/bech32m': 10.0.0(@penumbra-zone/protobuf@6.3.0(@bufbuild/protobuf@1.10.0)) '@penumbra-zone/getters': 20.0.0(@bufbuild/protobuf@1.10.0)(@penumbra-zone/bech32m@10.0.0(@penumbra-zone/protobuf@6.3.0(@bufbuild/protobuf@1.10.0)))(@penumbra-zone/protobuf@6.3.0(@bufbuild/protobuf@1.10.0)) @@ -9418,7 +9418,7 @@ snapshots: debug: 4.3.7 enhanced-resolve: 5.17.1 eslint: 9.10.0(jiti@1.21.6) - eslint-module-utils: 2.11.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@9.10.0(jiti@1.21.6)) + eslint-module-utils: 2.11.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.7.2))(eslint-plugin-import-x@0.5.3(eslint@8.57.0)(typescript@5.7.2))(eslint-plugin-import@2.30.0)(eslint@9.10.0(jiti@1.21.6)))(eslint@9.10.0(jiti@1.21.6)) fast-glob: 3.3.2 get-tsconfig: 4.8.0 is-bun-module: 1.2.1 @@ -9438,13 +9438,13 @@ snapshots: debug: 4.3.7 enhanced-resolve: 5.17.1 eslint: 8.57.0 - eslint-module-utils: 2.11.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0) + eslint-module-utils: 2.11.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import-x@0.5.3(eslint@8.57.0)(typescript@5.7.2))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.8.0 is-bun-module: 1.2.1 is-glob: 4.0.3 optionalDependencies: - eslint-plugin-import: 2.30.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.7.2))(eslint@8.57.0) + eslint-plugin-import: 2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0) eslint-plugin-import-x: 0.5.3(eslint@9.10.0(jiti@1.21.6))(typescript@5.7.2) transitivePeerDependencies: - '@typescript-eslint/parser' @@ -9452,7 +9452,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.11.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.10.0(jiti@1.21.6)): + eslint-module-utils@2.11.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.7.2))(eslint-plugin-import-x@0.5.3(eslint@8.57.0)(typescript@5.7.2))(eslint-plugin-import@2.30.0)(eslint@9.10.0(jiti@1.21.6)))(eslint@9.10.0(jiti@1.21.6)): dependencies: debug: 3.2.7 optionalDependencies: @@ -9463,7 +9463,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.11.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3)(eslint@9.10.0(jiti@1.21.6)): + eslint-module-utils@2.11.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.7.2))(eslint-plugin-import-x@0.5.3(eslint@8.57.0)(typescript@5.7.2))(eslint-plugin-import@2.30.0)(eslint@9.10.0(jiti@1.21.6)))(eslint@9.10.0(jiti@1.21.6)): dependencies: debug: 3.2.7 optionalDependencies: @@ -9473,7 +9473,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.11.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0): + eslint-module-utils@2.11.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import-x@0.5.3(eslint@8.57.0)(typescript@5.7.2))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0): dependencies: debug: 3.2.7 optionalDependencies: @@ -9512,7 +9512,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.11.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.10.0(jiti@1.21.6)) + eslint-module-utils: 2.11.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.7.2))(eslint-plugin-import-x@0.5.3(eslint@8.57.0)(typescript@5.7.2))(eslint-plugin-import@2.30.0)(eslint@9.10.0(jiti@1.21.6)))(eslint@9.10.0(jiti@1.21.6)) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -9540,7 +9540,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.11.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0) + eslint-module-utils: 2.11.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import-x@0.5.3(eslint@8.57.0)(typescript@5.7.2))(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 diff --git a/src/features/star-pair/index.ts b/src/features/star-pair/index.ts new file mode 100644 index 00000000..a46232af --- /dev/null +++ b/src/features/star-pair/index.ts @@ -0,0 +1,3 @@ +export { StarButton } from './star-button'; +export { starStore } from './store'; +export type { Pair } from './storage'; diff --git a/src/features/star-pair/star-button.tsx b/src/features/star-pair/star-button.tsx new file mode 100644 index 00000000..96d6d039 --- /dev/null +++ b/src/features/star-pair/star-button.tsx @@ -0,0 +1,40 @@ +import { FC, MouseEventHandler } from 'react'; +import { observer } from 'mobx-react-lite'; +import { Star } from 'lucide-react'; +import { Button } from '@penumbra-zone/ui/Button'; +import { Density } from '@penumbra-zone/ui/Density'; +import StarFilled from './star-filled.svg'; +import type { Pair } from './storage'; +import { starStore } from './store'; + +export interface StarButtonProps { + pair: Pair; + adornment?: boolean; +} + +export const StarButton = observer(({ pair, adornment }: StarButtonProps) => { + const { star, unstar, isStarred } = starStore; + const starred = isStarred(pair); + + const onClick: MouseEventHandler = event => { + event.stopPropagation(); + if (starred) { + unstar(pair); + } else { + star(pair); + } + }; + + return ( + + + + ); +}); diff --git a/src/features/star-pair/star-filled.svg b/src/features/star-pair/star-filled.svg new file mode 100644 index 00000000..385047c1 --- /dev/null +++ b/src/features/star-pair/star-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/features/star-pair/storage.ts b/src/features/star-pair/storage.ts new file mode 100644 index 00000000..23c29b3d --- /dev/null +++ b/src/features/star-pair/storage.ts @@ -0,0 +1,35 @@ +import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; + +export interface Pair { + base: Metadata; + quote: Metadata; +} + +const STAR_STORE_LS_KEY = 'star-pairs-store'; + +export const getStarredPairs = (): Pair[] => { + try { + const data = JSON.parse(localStorage.getItem(STAR_STORE_LS_KEY) ?? '[]') as { + base: string; + quote: string; + }[]; + return data.map(pair => ({ + base: Metadata.fromJson(pair.base), + quote: Metadata.fromJson(pair.quote), + })); + } catch (_) { + return []; + } +}; + +export const setStarredPairs = (pairs: Pair[]): void => { + localStorage.setItem( + STAR_STORE_LS_KEY, + JSON.stringify( + pairs.map(pair => ({ + base: pair.base.toJson(), + quote: pair.quote.toJson(), + })), + ), + ); +}; diff --git a/src/features/star-pair/store.ts b/src/features/star-pair/store.ts new file mode 100644 index 00000000..c2201f3d --- /dev/null +++ b/src/features/star-pair/store.ts @@ -0,0 +1,34 @@ +import { makeAutoObservable } from 'mobx'; +import { Pair, getStarredPairs, setStarredPairs } from './storage'; + +class StarStateStore { + pairs: Pair[] = []; + + constructor() { + makeAutoObservable(this); + } + + star = (pair: Pair) => { + this.pairs = [...this.pairs, pair]; + setStarredPairs(this.pairs); + }; + + unstar = (pair: Pair) => { + this.pairs = this.pairs.filter( + value => !value.base.equals(pair.base) || !value.quote.equals(pair.quote), + ); + setStarredPairs(this.pairs); + }; + + setup() { + this.pairs = getStarredPairs(); + } + + isStarred = (pair: Pair): boolean => { + return this.pairs.some( + value => value.base.symbol === pair.base.symbol && value.quote.symbol === pair.quote.symbol, + ); + }; +} + +export const starStore = new StarStateStore(); diff --git a/src/pages/trade/api/use-pairs.ts b/src/pages/trade/api/use-pairs.ts new file mode 100644 index 00000000..15b64dea --- /dev/null +++ b/src/pages/trade/api/use-pairs.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import type { PairData } from '@/shared/api/server/summary/pairs'; +import { apiFetch } from '@/shared/utils/api-fetch'; + +// Fetches the array of popular (sorted by liquidity) pairs +export const usePairs = () => { + return useQuery({ + queryKey: ['pairs'], + queryFn: async () => { + return apiFetch('/api/pairs'); + }, + }); +}; diff --git a/src/pages/trade/ui/pair-selector.tsx b/src/pages/trade/ui/pair-selector.tsx deleted file mode 100644 index d7bdfee8..00000000 --- a/src/pages/trade/ui/pair-selector.tsx +++ /dev/null @@ -1,121 +0,0 @@ -'use client'; - -import { observer } from 'mobx-react-lite'; -import { ArrowLeftRight } from 'lucide-react'; -import { useRouter } from 'next/navigation'; -import { getMetadataFromBalancesResponse } from '@penumbra-zone/getters/balances-response'; -import { - AssetSelector, - AssetSelectorValue, - isBalancesResponse, - isMetadata, -} from '@penumbra-zone/ui/AssetSelector'; -import { Button } from '@penumbra-zone/ui/Button'; -import { useAssets } from '@/shared/api/assets'; -import { useBalances } from '@/shared/api/balances'; -import { PagePath } from '@/shared/const/pages.ts'; -import { usePathToMetadata } from '../model/use-path.ts'; -import { Skeleton } from '@/shared/ui/skeleton'; -import { Density } from '@penumbra-zone/ui/Density'; - -const handleRouting = ({ - router, - baseAsset, - quoteAsset, -}: { - router: ReturnType; - baseAsset: AssetSelectorValue | undefined; - quoteAsset: AssetSelectorValue | undefined; -}) => { - if (!baseAsset || !quoteAsset) { - throw new Error('Url malformed'); - } - - let primarySymbol: string; - let numeraireSymbol: string; - - // TODO: Create new getter in /web repo - if (isMetadata(baseAsset)) { - primarySymbol = baseAsset.symbol; - } else if (isBalancesResponse(baseAsset)) { - primarySymbol = getMetadataFromBalancesResponse(baseAsset).symbol; - } else { - throw new Error('unrecognized metadata for primary asset'); - } - - if (isMetadata(quoteAsset)) { - numeraireSymbol = quoteAsset.symbol; - } else if (isBalancesResponse(quoteAsset)) { - numeraireSymbol = getMetadataFromBalancesResponse(quoteAsset).symbol; - } else { - throw new Error('unrecognized metadata for numeraireSymbol asset'); - } - - router.push(`${PagePath.Trade}/${primarySymbol}/${numeraireSymbol}`); -}; - -export interface PairSelectorProps { - dialogTitle?: string; - disabled?: boolean; -} - -export const PairSelector = observer(({ disabled, dialogTitle }: PairSelectorProps) => { - const router = useRouter(); - const { data: assets } = useAssets(); - const { data: balances } = useBalances(); - const { baseAsset, quoteAsset, error, isLoading } = usePathToMetadata(); - - if ( - error instanceof Error && - ![ - 'ConnectError', - 'PenumbraNotInstalledError', - 'PenumbraProviderNotAvailableError', - 'PenumbraProviderNotConnectedError', - ].includes(error.name) - ) { - return
Error loading pair selector: ${String(error)}
; - } - - if (isLoading || !baseAsset || !quoteAsset) { - return ( -
- -
- ); - } - - return ( - -
- handleRouting({ router, baseAsset: val, quoteAsset: quoteAsset })} - /> - - - - handleRouting({ router, baseAsset: baseAsset, quoteAsset: val })} - /> -
-
- ); -}); diff --git a/src/pages/trade/ui/pair-selector/default-results.tsx b/src/pages/trade/ui/pair-selector/default-results.tsx new file mode 100644 index 00000000..d4a8a648 --- /dev/null +++ b/src/pages/trade/ui/pair-selector/default-results.tsx @@ -0,0 +1,134 @@ +import { Search, Ban } from 'lucide-react'; +import { observer } from 'mobx-react-lite'; +import { Text } from '@penumbra-zone/ui/Text'; +import { Dialog } from '@penumbra-zone/ui/Dialog'; +import { AssetIcon } from '@penumbra-zone/ui/AssetIcon'; +import { Pair, StarButton, starStore } from '@/features/star-pair'; +import { usePairs } from '@/pages/trade/api/use-pairs'; +import { shortify } from '@penumbra-zone/types/shortify'; +import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view'; +import { Skeleton } from '@/shared/ui/skeleton'; +import { LoadingAsset } from './loading-asset'; + +export interface DefaultResultsProps { + onSelect: (pair: Pair) => void; +} + +export const DefaultResults = observer(({ onSelect }: DefaultResultsProps) => { + const { pairs: starred } = starStore; + const { data: suggested, isLoading, error } = usePairs(); + + if (isLoading) { + return ( +
+
+ +
+ +
+ {new Array(5).fill(null).map((_, i) => ( + + ))} +
+
+ ); + } + + if (error) { + return ( +
+ + An error occurred when loading data from the blockchain + {/* TODO: add details button */} +
+ ); + } + + if (!starred.length && !suggested?.length) { + return ( +
+ + No results +
+ ); + } + + return ( + <> + {!!starred.length && ( +
+ Starred + + +
+ {starred.map(({ base, quote }) => ( + + + {base.symbol}/{quote.symbol} + +
+ } + endAdornment={} + startAdornment={ + <> +
+ +
+
+ +
+ + } + onSelect={() => onSelect({ base, quote })} + /> + ))} +
+ + + )} + +
+ Suggested + + +
+ {suggested?.map(({ baseAsset: base, quoteAsset: quote, volume }) => ( + + {base.symbol}/{quote.symbol} + + } + description={ +
+ + Vol ${shortify(Number(getFormattedAmtFromValueView(volume)))} + +
+ } + endAdornment={} + startAdornment={ + <> +
+ +
+
+ +
+ + } + onSelect={() => onSelect({ base, quote })} + /> + ))} +
+
+
+ + ); +}); diff --git a/src/pages/trade/ui/pair-selector/filter-input.tsx b/src/pages/trade/ui/pair-selector/filter-input.tsx new file mode 100644 index 00000000..3623c9a4 --- /dev/null +++ b/src/pages/trade/ui/pair-selector/filter-input.tsx @@ -0,0 +1,59 @@ +import cn from 'clsx'; +import { Search, X } from 'lucide-react'; +import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { AssetIcon } from '@penumbra-zone/ui/AssetIcon'; +import { Text } from '@penumbra-zone/ui/Text'; +import { Button } from '@penumbra-zone/ui/Button'; +import { TextInput } from '@penumbra-zone/ui/TextInput'; +import { forwardRef } from 'react'; +import { Density } from '@penumbra-zone/ui/Density'; + +export interface FilterInputProps { + asset?: Metadata; + onClear: VoidFunction; + value: string; + placeholder: string; + onChange: (value: string) => void; +} + +export const FilterInput = forwardRef( + ({ asset, onClear, onChange, value, placeholder }, ref) => { + const deselect = () => { + onChange(''); + onClear(); + }; + + return ( + <> + {asset && ( +
+
+ + {asset.symbol} +
+ + + +
+ )} + +
+ } + ref={ref} + /> +
+ + ); + }, +); +FilterInput.displayName = 'FilterInput'; diff --git a/src/pages/trade/ui/pair-selector/handle-routing.ts b/src/pages/trade/ui/pair-selector/handle-routing.ts new file mode 100644 index 00000000..f3b90dfe --- /dev/null +++ b/src/pages/trade/ui/pair-selector/handle-routing.ts @@ -0,0 +1,44 @@ +import { useRouter } from 'next/navigation'; +import { getMetadataFromBalancesResponse } from '@penumbra-zone/getters/balances-response'; +import { PagePath } from '@/shared/const/pages'; +import { + AssetSelectorValue, + isBalancesResponse, + isMetadata, +} from '@penumbra-zone/ui/AssetSelector'; + +export const handleRouting = ({ + router, + baseAsset, + quoteAsset, +}: { + router: ReturnType; + baseAsset: AssetSelectorValue | undefined; + quoteAsset: AssetSelectorValue | undefined; +}) => { + if (!baseAsset || !quoteAsset) { + throw new Error('Url malformed'); + } + + let primarySymbol: string; + let numeraireSymbol: string; + + // TODO: Create new getter in /web repo + if (isMetadata(baseAsset)) { + primarySymbol = baseAsset.symbol; + } else if (isBalancesResponse(baseAsset)) { + primarySymbol = getMetadataFromBalancesResponse(baseAsset).symbol; + } else { + throw new Error('unrecognized metadata for primary asset'); + } + + if (isMetadata(quoteAsset)) { + numeraireSymbol = quoteAsset.symbol; + } else if (isBalancesResponse(quoteAsset)) { + numeraireSymbol = getMetadataFromBalancesResponse(quoteAsset).symbol; + } else { + throw new Error('unrecognized metadata for numeraireSymbol asset'); + } + + router.push(`${PagePath.Trade}/${primarySymbol}/${numeraireSymbol}`); +}; diff --git a/src/pages/trade/ui/pair-selector/index.ts b/src/pages/trade/ui/pair-selector/index.ts new file mode 100644 index 00000000..65446237 --- /dev/null +++ b/src/pages/trade/ui/pair-selector/index.ts @@ -0,0 +1 @@ +export { PairSelector } from './selector'; diff --git a/src/pages/trade/ui/pair-selector/loading-asset.tsx b/src/pages/trade/ui/pair-selector/loading-asset.tsx new file mode 100644 index 00000000..fa35bc61 --- /dev/null +++ b/src/pages/trade/ui/pair-selector/loading-asset.tsx @@ -0,0 +1,22 @@ +import { Skeleton } from '@/shared/ui/skeleton'; + +export const LoadingAsset = () => { + return ( +
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+ ); +}; diff --git a/src/pages/trade/ui/pair-selector/search-results.tsx b/src/pages/trade/ui/pair-selector/search-results.tsx new file mode 100644 index 00000000..7af41609 --- /dev/null +++ b/src/pages/trade/ui/pair-selector/search-results.tsx @@ -0,0 +1,196 @@ +import { observer } from 'mobx-react-lite'; +import { Search } from 'lucide-react'; +import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; +import { + getAddressIndex, + getBalanceView, + getMetadataFromBalancesResponse, +} from '@penumbra-zone/getters/balances-response'; +import { Text } from '@penumbra-zone/ui/Text'; +import { Dialog } from '@penumbra-zone/ui/Dialog'; +import { AssetIcon } from '@penumbra-zone/ui/AssetIcon'; +import { + groupAndSortBalances, + AssetSelectorValue, + isBalancesResponse, +} from '@penumbra-zone/ui/AssetSelector'; +import { Button } from '@penumbra-zone/ui/Button'; +import { useAssets } from '@/shared/api/assets'; +import { useBalances } from '@/shared/api/balances'; +import { connectionStore } from '@/shared/model/connection'; +import { ValueViewComponent } from '@penumbra-zone/ui/ValueView'; +import { recentPairsStore } from './store'; + +export interface SearchResultsProps { + onSelect: (asset: Metadata) => void; + onClear: VoidFunction; + search?: string; + showConfirm: boolean; + onConfirm: VoidFunction; +} + +const filterAsset = (asset: Metadata, search: string): boolean => { + return ( + asset.symbol.toLowerCase().includes(search.toLowerCase()) || + asset.description.toLowerCase().includes(search.toLowerCase()) + ); +}; + +const useFilteredAssets = (options: AssetSelectorValue[], search: string) => { + return options.filter(option => { + if (isBalancesResponse(option)) { + const metadata = getMetadataFromBalancesResponse(option); + return filterAsset(metadata, search); + } + return filterAsset(option, search); + }); +}; + +const mergeOptions = ( + assets: Metadata[], + balances: BalancesResponse[], + account: number, +): AssetSelectorValue[] => { + const grouped = groupAndSortBalances(balances); + const balancesPerAccount = grouped.find(group => group[0] === account.toString())?.[1] ?? []; + const filteredAssets = assets + .filter( + asset => + !balancesPerAccount.some( + balance => getMetadataFromBalancesResponse(balance).symbol === asset.symbol, + ), + ) + .sort((a, b) => Number(b.priorityScore) - Number(a.priorityScore)); + return [...balancesPerAccount, ...filteredAssets]; +}; + +export const SearchResults = observer( + ({ onSelect, onClear, search, showConfirm, onConfirm }: SearchResultsProps) => { + const { recent, add } = recentPairsStore; + const { subaccount } = connectionStore; + + const { data: assets } = useAssets(); + const { data: balances } = useBalances(); + + const merged = mergeOptions(assets ?? [], balances ?? [], subaccount); + const filtered = useFilteredAssets(merged, search ?? ''); + + const onClick = (asset: Metadata) => { + add(asset); + onSelect(asset); + }; + + if (!filtered.length) { + return ( +
+ + No results +
+ ); + } + + return ( + <> + {!search && !!recent.length && ( +
+ Recent + +
+ {recent.map(asset => ( + } + title={ +
+ {asset.symbol} +
+ } + description={ + asset.name && ( +
+ + {asset.name} + +
+ ) + } + onSelect={() => onClick(asset)} + /> + ))} +
+
+
+ )} + +
+ Search results + +
+ {filtered.map(option => { + const asset = isBalancesResponse(option) + ? getMetadataFromBalancesResponse(option) + : option; + const balance = isBalancesResponse(option) + ? { + addressIndexAccount: getAddressIndex.optional(option)?.account, + valueView: getBalanceView.optional(option), + } + : undefined; + + return ( + } + endAdornment={ + balance && ( +
+ +
+ ) + } + title={ +
+ {asset.symbol} +
+ } + description={ + asset.name && ( +
+ + {asset.name} + +
+ ) + } + onSelect={() => onClick(asset)} + /> + ); + })} +
+
+
+ +
+ {showConfirm && ( + + )} + +
+ +
+
+ + ); + }, +); diff --git a/src/pages/trade/ui/pair-selector/selector.tsx b/src/pages/trade/ui/pair-selector/selector.tsx new file mode 100644 index 00000000..4136c643 --- /dev/null +++ b/src/pages/trade/ui/pair-selector/selector.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { observer } from 'mobx-react-lite'; +import { useRouter } from 'next/navigation'; +import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { Dialog } from '@penumbra-zone/ui/Dialog'; +import { Skeleton } from '@/shared/ui/skeleton'; +import { Density } from '@penumbra-zone/ui/Density'; +import { Text } from '@penumbra-zone/ui/Text'; +import { StarButton } from '@/features/star-pair'; +import { usePathToMetadata } from '../../model/use-path.ts'; +import { handleRouting } from './handle-routing.ts'; +import { useFocus } from './use-focus.ts'; +import { Trigger } from './trigger'; +import { SearchResults } from './search-results'; +import { DefaultResults } from './default-results'; +import { FilterInput } from './filter-input'; + +export const PairSelector = observer(() => { + const router = useRouter(); + const { baseAsset, quoteAsset, error, isLoading } = usePathToMetadata(); + + const [isOpen, setIsOpen] = useState(false); + const [baseFilter, setBaseFilter] = useState(''); + const [quoteFilter, setQuoteFilter] = useState(''); + const [selectedBase, setSelectedBase] = useState(); + const [selectedQuote, setSelectedQuote] = useState(); + + const { baseRef, quoteRef, focusedType, clearFocus } = useFocus(isOpen); + + const onClose = useCallback(() => { + setIsOpen(false); + clearFocus(); + setQuoteFilter(''); + setBaseFilter(''); + setSelectedQuote(undefined); + setSelectedBase(undefined); + }, [clearFocus]); + + const onSelect = useCallback( + (base: Metadata, quote: Metadata) => { + handleRouting({ router, baseAsset: base, quoteAsset: quote }); + onClose(); + }, + [router, onClose], + ); + + const onClear = () => { + clearFocus(); + setQuoteFilter(''); + setBaseFilter(''); + }; + + const onConfirm = () => { + if (selectedBase && selectedQuote) { + onSelect(selectedBase, selectedQuote); + } + }; + + if ( + error instanceof Error && + ![ + 'ConnectError', + 'PenumbraNotInstalledError', + 'PenumbraProviderNotAvailableError', + 'PenumbraProviderNotConnectedError', + ].includes(error.name) + ) { + return
Error loading pair selector: ${String(error)}
; + } + + if (isLoading || !baseAsset || !quoteAsset) { + return ( +
+ +
+ ); + } + + return ( +
+ + + + setIsOpen(true)} pair={{ base: baseAsset, quote: quoteAsset }} /> + + + {/* Focus catcher. If this button wouldn't exist, the focus would go to the first input, which is undesirable */} + +
+ ); +}); diff --git a/src/pages/trade/ui/pair-selector/storage.ts b/src/pages/trade/ui/pair-selector/storage.ts new file mode 100644 index 00000000..1722db1a --- /dev/null +++ b/src/pages/trade/ui/pair-selector/storage.ts @@ -0,0 +1,16 @@ +import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; + +const RECENT_STORE_LS_KEY = 'recent-pairs-store'; + +export const getRecentAssets = (): Metadata[] => { + try { + const data = JSON.parse(localStorage.getItem(RECENT_STORE_LS_KEY) ?? '[]') as string[]; + return data.map(asset => Metadata.fromJson(asset)); + } catch (_) { + return []; + } +}; + +export const setRecentAssets = (assets: Metadata[]): void => { + localStorage.setItem(RECENT_STORE_LS_KEY, JSON.stringify(assets.map(asset => asset.toJson()))); +}; diff --git a/src/pages/trade/ui/pair-selector/store.ts b/src/pages/trade/ui/pair-selector/store.ts new file mode 100644 index 00000000..75c51995 --- /dev/null +++ b/src/pages/trade/ui/pair-selector/store.ts @@ -0,0 +1,27 @@ +import { makeAutoObservable } from 'mobx'; +import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { setRecentAssets, getRecentAssets } from './storage'; + +const MAX_RECENT_PAIRS = 5; + +class RecentPairsStore { + recent: Metadata[] = []; + + constructor() { + makeAutoObservable(this); + } + + // Add a pair to the stack of recent pairs, the old pairs get auto removed + add = (asset: Metadata) => { + if (!this.recent.some(a => a.symbol === asset.symbol)) { + this.recent = [asset, ...this.recent.slice(0, MAX_RECENT_PAIRS - 1)]; + setRecentAssets(this.recent); + } + }; + + setup() { + this.recent = getRecentAssets(); + } +} + +export const recentPairsStore = new RecentPairsStore(); diff --git a/src/pages/trade/ui/pair-selector/trigger.tsx b/src/pages/trade/ui/pair-selector/trigger.tsx new file mode 100644 index 00000000..00457300 --- /dev/null +++ b/src/pages/trade/ui/pair-selector/trigger.tsx @@ -0,0 +1,33 @@ +import { ChevronDown } from 'lucide-react'; +import { AssetIcon } from '@penumbra-zone/ui/AssetIcon'; +import { Dialog } from '@penumbra-zone/ui/Dialog'; +import { Text } from '@penumbra-zone/ui/Text'; +import { Pair } from '@/features/star-pair'; + +export interface TriggerProps { + onClick: VoidFunction; + pair: Pair; +} + +export const Trigger = ({ onClick, pair }: TriggerProps) => { + return ( + + + + ); +}; diff --git a/src/pages/trade/ui/pair-selector/use-focus.ts b/src/pages/trade/ui/pair-selector/use-focus.ts new file mode 100644 index 00000000..3c2de868 --- /dev/null +++ b/src/pages/trade/ui/pair-selector/use-focus.ts @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +type AssetType = 'base' | 'quote'; + +export const useFocus = (isOpen: boolean) => { + const baseRef = useRef(null); + const quoteRef = useRef(null); + + const [focusedType, setFocusedType] = useState(); + + const manageFocus = useCallback( + (type: AssetType) => { + const onFocusIn = () => setFocusedType(type); + + const ref = type === 'base' ? baseRef : quoteRef; + let input = ref.current; + + if (isOpen) { + setTimeout(() => { + if (ref.current) { + input = ref.current; + input.addEventListener('focusin', onFocusIn); + } + }, 0); + } + + return () => { + if (input) { + input.removeEventListener('focusin', onFocusIn); + } + }; + }, + [isOpen], + ); + + const clearFocus = () => { + setFocusedType(undefined); + }; + + useEffect(() => { + return manageFocus('base'); + }, [manageFocus]); + + useEffect(() => { + return manageFocus('quote'); + }, [manageFocus]); + + return { focusedType, baseRef, quoteRef, clearFocus }; +}; diff --git a/src/shared/api/server/summary/pairs.ts b/src/shared/api/server/summary/pairs.ts new file mode 100644 index 00000000..53deba04 --- /dev/null +++ b/src/shared/api/server/summary/pairs.ts @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server'; +import { pindexer } from '@/shared/database'; +import { serialize, Serialized } from '@/shared/utils/serializer'; +import { + AssetId, + Metadata, + ValueView, +} from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { ChainRegistryClient } from '@penumbra-labs/registry'; +import { toValueView } from '@/shared/utils/value-view'; +import { getDisplayDenomExponent } from '@penumbra-zone/getters/metadata'; + +const getAssetById = (allAssets: Metadata[], id: Buffer): Metadata | undefined => { + return allAssets.find(asset => { + return asset.penumbraAssetId?.equals(new AssetId({ inner: id })); + }); +}; + +export interface PairData { + baseAsset: Metadata; + quoteAsset: Metadata; + volume: ValueView; +} + +export type PairsResponse = Serialized[] | { error: string }; + +export async function GET(): Promise> { + const chainId = process.env['PENUMBRA_CHAIN_ID']; + if (!chainId) { + return NextResponse.json({ error: 'PENUMBRA_CHAIN_ID is not set' }, { status: 500 }); + } + + const registryClient = new ChainRegistryClient(); + const registry = await registryClient.remote.get(chainId); + const allAssets = registry.getAllAssets(); + + const stablecoins = allAssets.filter(asset => ['USDT', 'USDC', 'USDY'].includes(asset.symbol)); + const usdc = stablecoins.find(asset => asset.symbol === 'USDC'); + + const results = await pindexer.pairs({ + // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style -- usdc is defined + usdc: usdc?.penumbraAssetId as AssetId, + stablecoins: stablecoins.map(asset => asset.penumbraAssetId) as AssetId[], + }); + + const pairs = (await Promise.all( + results.map(summary => { + const baseAsset = getAssetById(allAssets, summary.asset_start); + const quoteAsset = getAssetById(allAssets, summary.asset_end); + if (!baseAsset || !quoteAsset) { + return undefined; + } + + let volume = toValueView({ + amount: summary.liquidity, + metadata: quoteAsset, + }); + + if (summary.usdc_price) { + // Custom volume calculation for USDC pairs + // TODO: change to a better method (probably create `pnum.multiply()` method and use it) + const expDiff = Math.abs( + getDisplayDenomExponent(quoteAsset) - getDisplayDenomExponent(usdc), + ); + const result = summary.liquidity * summary.usdc_price * 10 ** expDiff; + volume = toValueView({ + amount: Math.floor(result), + metadata: quoteAsset, + }); + } + + return serialize({ + baseAsset, + quoteAsset, + volume, + }); + }), + )) as Serialized[]; + + return NextResponse.json(pairs.filter(Boolean)); +} diff --git a/src/shared/database/index.ts b/src/shared/database/index.ts index 611ee257..1fed37ea 100644 --- a/src/shared/database/index.ts +++ b/src/shared/database/index.ts @@ -75,6 +75,42 @@ class Pindexer { .execute(); } + async pairs({ usdc, stablecoins }: { usdc: AssetId; stablecoins: AssetId[] }) { + const usdcTable = this.db + .selectFrom('dex_ex_pairs_summary') + .where('asset_end', '=', Buffer.from(usdc.inner)) + .where('the_window', '=', '1m') + .groupBy(['asset_end', 'asset_start', 'the_window']) + .selectAll(); + + const joined = this.db + .selectFrom('dex_ex_pairs_summary as outer') + // .distinct() + .selectAll('outer') + // get the usdc price of the quote asset + .leftJoin(usdcTable.as('usdc'), 'outer.asset_end', 'usdc.asset_start') + .select(['usdc.price as usdc_price']) + .where(exp => + exp.and([ + exp.eb('outer.the_window', '=', '1m'), + exp.eb('outer.price', '!=', 0), + // Filters out pairs where stablecoins are base assets (e.g. no USDC/UM, only UM/USDC) + exp.eb( + exp.ref('outer.asset_start'), + 'not in', + stablecoins.map(asset => Buffer.from(asset.inner)), + ), + ]), + ) + // sort desc by USDC equivalent of liquidity + .orderBy( + exp => sql`COALESCE(${exp.ref('usdc.price')}, 1) * ${exp.ref('outer.liquidity')} desc`, + ) + .limit(15); + + return joined.execute(); + } + async candles({ baseAsset, quoteAsset, diff --git a/src/widgets/header/ui/desktop-nav.tsx b/src/widgets/header/ui/desktop-nav.tsx index de27a89e..fa21b339 100644 --- a/src/widgets/header/ui/desktop-nav.tsx +++ b/src/widgets/header/ui/desktop-nav.tsx @@ -10,7 +10,7 @@ export const DesktopNav = () => { return (