From 0d40fe2ecd1cdcfe6d5594a7094eca012698dd9f Mon Sep 17 00:00:00 2001 From: Ola Stenberg Date: Wed, 18 Sep 2024 13:21:46 +0200 Subject: [PATCH 01/12] feat: init new token-listing page --- apps/web/src/app/token-listing/layout.tsx | 12 + apps/web/src/app/token-listing/page.tsx | 293 ++++++++++++++++++++++ apps/web/src/app/token-listing/schema.ts | 33 +++ pnpm-lock.yaml | 4 + 4 files changed, 342 insertions(+) create mode 100644 apps/web/src/app/token-listing/layout.tsx create mode 100644 apps/web/src/app/token-listing/page.tsx create mode 100644 apps/web/src/app/token-listing/schema.ts diff --git a/apps/web/src/app/token-listing/layout.tsx b/apps/web/src/app/token-listing/layout.tsx new file mode 100644 index 0000000000..338a92c96b --- /dev/null +++ b/apps/web/src/app/token-listing/layout.tsx @@ -0,0 +1,12 @@ +import React from 'react' + +import { QueryClientProvider } from '../../providers/query-client-provider' +import { WagmiProvider } from '../../providers/wagmi-provider' + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/apps/web/src/app/token-listing/page.tsx b/apps/web/src/app/token-listing/page.tsx new file mode 100644 index 0000000000..fc5ec902f9 --- /dev/null +++ b/apps/web/src/app/token-listing/page.tsx @@ -0,0 +1,293 @@ +'use client' + +import { CameraIcon } from '@heroicons/react/24/outline' +import { zodResolver } from '@hookform/resolvers/zod' +import { useApplyForTokenList } from '@sushiswap/react-query' +import { + Card, + Container, + Form, + FormControl, + FormField, + FormItem, + FormMessage, + Label, + LinkExternal, + Message, + NetworkSelector, + SelectIcon, + Separator, + TextField, + typographyVariants, +} from '@sushiswap/ui' +import { Button } from '@sushiswap/ui' +import { NetworkIcon } from '@sushiswap/ui/icons/NetworkIcon' +import React, { useCallback, useEffect } from 'react' +import { DropzoneOptions, useDropzone } from 'react-dropzone' +import { useForm } from 'react-hook-form' +import { Chain, ChainId } from 'sushi/chain' +import { type Address, isAddress } from 'viem' + +import { useTokenWithCache } from 'src/lib/wagmi/hooks/tokens/useTokenWithCache' +import { SUPPORTED_CHAIN_IDS } from '../../config' +import { + ApplyForTokenListListType, + ApplyForTokenListTokenSchema, + ApplyForTokenListTokenSchemaType, +} from './schema' + +const Metrics = () => { + return ( + <> +

+ Metrics +

+ +
+ Age + Daily + Market Cap + Holder Count +
+ +
+ 23 Days + $709k + $400k + 510 +
+
+ + ) +} + +export default function Partner() { + const methods = useForm({ + mode: 'all', + resolver: zodResolver(ApplyForTokenListTokenSchema), + defaultValues: { + chainId: ChainId.ETHEREUM, + listType: ApplyForTokenListListType.DEFAULT, + logoFile: '', + tokenAddress: '', + }, + }) + + const [chainId, tokenAddress, logoFile] = methods.watch([ + 'chainId', + 'tokenAddress', + 'logoFile', + ]) + + const { data: token, isError: isTokenError } = useTokenWithCache({ + address: tokenAddress as Address, + chainId, + enabled: isAddress(tokenAddress, { strict: false }), + }) + const { mutate, isPending, data, status } = useApplyForTokenList() + + useEffect(() => { + if (isTokenError) + methods.setError('tokenAddress', { + type: 'custom', + message: 'Token not found', + }) + else methods.clearErrors('tokenAddress') + }, [methods, isTokenError]) + + const onDrop = useCallback>( + (acceptedFiles) => { + acceptedFiles.forEach((file) => { + const reader = new FileReader() + reader.readAsDataURL(file) + reader.addEventListener('load', () => { + if (reader.result) { + const imageAsBase64 = reader.result.toString() + const image = document.createElement('img') + image.src = imageAsBase64 + image.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = 128 + canvas.height = 128 + const context = canvas.getContext('2d', { alpha: false }) + if (context) { + context.drawImage(image, 0, 0, canvas.width, canvas.height) + const resizedImageAsBase64 = canvas.toDataURL('image/jpeg') + methods.setValue('logoFile', resizedImageAsBase64) + } + } + } + }) + }) + }, + [methods], + ) + + const { getRootProps, getInputProps } = useDropzone({ + onDrop, + accept: { 'image/jpeg': ['.jpeg', '.jpg'] }, + maxFiles: 1, + }) + + const onSubmit = (values: ApplyForTokenListTokenSchemaType) => { + if (token?.symbol) { + mutate({ + ...values, + tokenName: token.name, + tokenDecimals: token.decimals, + tokenSymbol: token.symbol, + }) + } + } + + return ( + <> +
+
+

+ Community List +

+

+ Get your token verified by Sushi's Community. +

+
+
+
+ +
+ +
+ ( + + + +
+ + + +
+
+ + The network your token is deployed on. + +
+ )} + /> + { + return ( + + + + + + + The contract address of your token. + + + ) + }} + /> + + + + +
+
+ +
+ +
+ {logoFile ? ( + icon + ) : null} +
+
+
+ + Allowed formats: .jpeg, .jpg
+ Minimum dimensions are 128x128. +
+
+ {status === 'error' ? ( + + Oops! Something went wrong when trying to execute your + request. + + ) : null} + {status === 'success' ? ( + + Successfully send your whitelisting request! View your + request{' '} + + here + + + ) : null} +
+ +
+
+
+ +
+
+ + ) +} diff --git a/apps/web/src/app/token-listing/schema.ts b/apps/web/src/app/token-listing/schema.ts new file mode 100644 index 0000000000..b5d66cb2ec --- /dev/null +++ b/apps/web/src/app/token-listing/schema.ts @@ -0,0 +1,33 @@ +import { ChainId } from 'sushi/chain' +import { getAddress, isAddress } from 'viem' +import { z } from 'zod' + +export enum ApplyForTokenListListType { + DEFAULT = 'default-token-list', + COMMUNITY = 'community-token-list', +} + +const ZpdAddress = z + .string() + .refine((val) => (val ? isAddress(val) : false), 'Invalid address') + +export const ApplyForTokenListTokenSchema = z.object({ + tokenAddress: ZpdAddress.transform( + (tokenAddress) => getAddress(tokenAddress) as string, + ), + chainId: z.coerce + .number() + .transform((chainId) => chainId as ChainId) + .default(ChainId.ETHEREUM), + listType: z + .enum([ + ApplyForTokenListListType.DEFAULT, + ApplyForTokenListListType.COMMUNITY, + ]) + .default(ApplyForTokenListListType.DEFAULT), + logoFile: z.string(), +}) + +export type ApplyForTokenListTokenSchemaType = z.infer< + typeof ApplyForTokenListTokenSchema +> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 861befc2f5..59b23a8bd5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1290,6 +1290,10 @@ importers: specifier: 3.23.8 version: 3.23.8 + packages/sushi/dist/_cjs: {} + + packages/sushi/dist/_esm: {} + packages/telemetry: devDependencies: '@tsconfig/esm': From 873edbf1797c66bf55d109970d182fb977f1058c Mon Sep 17 00:00:00 2001 From: Ola Stenberg Date: Wed, 18 Sep 2024 18:40:24 +0200 Subject: [PATCH 02/12] feat: add token analysis/metricsto token listing page --- apps/web/src/app/token-listing/page.tsx | 267 ++++++++++++++---- apps/web/src/app/token-listing/schema.ts | 13 +- apps/web/src/lib/hooks/api/index.ts | 1 + .../web/src/lib/hooks/api/useTokenAnalysis.ts | 15 + .../queries/token-list-submission/index.ts | 1 + .../token-list-submission/token-analysis.ts | 104 +++++++ 6 files changed, 338 insertions(+), 63 deletions(-) create mode 100644 apps/web/src/lib/hooks/api/useTokenAnalysis.ts create mode 100644 packages/graph-client/src/subgraphs/data-api/queries/token-list-submission/index.ts create mode 100644 packages/graph-client/src/subgraphs/data-api/queries/token-list-submission/token-analysis.ts diff --git a/apps/web/src/app/token-listing/page.tsx b/apps/web/src/app/token-listing/page.tsx index fc5ec902f9..d22b1f79e8 100644 --- a/apps/web/src/app/token-listing/page.tsx +++ b/apps/web/src/app/token-listing/page.tsx @@ -2,8 +2,8 @@ import { CameraIcon } from '@heroicons/react/24/outline' import { zodResolver } from '@hookform/resolvers/zod' -import { useApplyForTokenList } from '@sushiswap/react-query' import { + Button, Card, Container, Form, @@ -18,25 +18,34 @@ import { SelectIcon, Separator, TextField, + classNames, typographyVariants, } from '@sushiswap/ui' -import { Button } from '@sushiswap/ui' import { NetworkIcon } from '@sushiswap/ui/icons/NetworkIcon' -import React, { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useMemo } from 'react' import { DropzoneOptions, useDropzone } from 'react-dropzone' import { useForm } from 'react-hook-form' +// import { type Address, isAddress } from 'viem' +import { useTokenAnalysis } from 'src/lib/hooks/api/useTokenAnalysis' import { Chain, ChainId } from 'sushi/chain' -import { type Address, isAddress } from 'viem' -import { useTokenWithCache } from 'src/lib/wagmi/hooks/tokens/useTokenWithCache' +import { + CheckCircleIcon, + ExclamationCircleIcon, +} from '@heroicons/react/20/solid' +import { TokenAnalysis } from '@sushiswap/graph-client/data-api/queries/token-list-submission' +import { formatNumber, formatUSD } from 'sushi' import { SUPPORTED_CHAIN_IDS } from '../../config' import { - ApplyForTokenListListType, ApplyForTokenListTokenSchema, ApplyForTokenListTokenSchemaType, } from './schema' -const Metrics = () => { +const Metrics = ({ + analysis, +}: { + analysis: TokenAnalysis | undefined +}) => { return ( <>

@@ -44,17 +53,141 @@ const Metrics = () => {

- Age - Daily - Market Cap - Holder Count + + Age {analysis ? ` (>${analysis.requirements.minimumAge} Days)` : ''} + + + Daily Volume{' '} + {analysis + ? ` (>${formatUSD(analysis.requirements.minimumVolumeUSD24h)})` + : ''} + + + Market Cap{' '} + {analysis + ? ` (>${formatUSD(analysis.requirements.minimumMarketcapUSD)})` + : ''} + + + Holder Count{' '} + {analysis ? ` (>${analysis.requirements.minimumHolders})` : ''} +
- +
- 23 Days - $709k - $400k - 510 +
= analysis.requirements.minimumAge + ? 'text-[#139B6D]' + : analysis && + analysis.metrics.age < analysis.requirements.minimumAge + ? 'text-[#B4303C]' + : 'text-muted-foreground', + )} + > + {analysis ? ( + <> + {analysis.metrics.age >= analysis.requirements.minimumAge ? ( + + ) : ( + + )} + {analysis.metrics.age} Days + + ) : ( + '-' + )} +
+
= + analysis.requirements.minimumVolumeUSD24h + ? 'text-[#139B6D]' + : analysis && + analysis.metrics.volumeUSD24h < + analysis.requirements.minimumVolumeUSD24h + ? 'text-[#B4303C]' + : 'text-muted-foreground', + )} + > + {analysis ? ( + <> + {analysis.metrics.volumeUSD24h >= + analysis.requirements.minimumVolumeUSD24h ? ( + + ) : ( + + )} + + {formatUSD(analysis.metrics.volumeUSD24h)} + + ) : ( + '-' + )} +
+ +
= + analysis.requirements.minimumMarketcapUSD + ? 'text-[#139B6D]' + : analysis && + analysis.metrics.marketcapUSD < + analysis.requirements.minimumMarketcapUSD + ? 'text-[#B4303C]' + : 'text-muted-foreground', + )} + > + {analysis ? ( + <> + {analysis.metrics.marketcapUSD >= + analysis.requirements.minimumMarketcapUSD ? ( + + ) : ( + + )} + + {formatUSD(analysis.metrics.marketcapUSD)} + + ) : ( + '-' + )} +
+ +
= analysis.requirements.minimumHolders + ? 'text-[#139B6D]' + : analysis && + analysis.metrics.holders < + analysis.requirements.minimumHolders + ? 'text-[#B4303C]' + : 'text-muted-foreground', + )} + > + {analysis ? ( + <> + {analysis.metrics.holders >= + analysis.requirements.minimumHolders ? ( + + ) : ( + + )} + + {formatNumber(analysis.metrics.holders)} + + ) : ( + '-' + )} +
@@ -67,24 +200,33 @@ export default function Partner() { resolver: zodResolver(ApplyForTokenListTokenSchema), defaultValues: { chainId: ChainId.ETHEREUM, - listType: ApplyForTokenListListType.DEFAULT, logoFile: '', - tokenAddress: '', + tokenAddress: undefined, }, }) - const [chainId, tokenAddress, logoFile] = methods.watch([ 'chainId', 'tokenAddress', 'logoFile', ]) - const { data: token, isError: isTokenError } = useTokenWithCache({ - address: tokenAddress as Address, + const { + data: analysis, + isLoading, + isError: isTokenError, + } = useTokenAnalysis({ + address: tokenAddress, chainId, - enabled: isAddress(tokenAddress, { strict: false }), }) - const { mutate, isPending, data, status } = useApplyForTokenList() + + const [isValid, reasoning] = useMemo(() => { + if (!analysis) return [false, []] + if (analysis.isExisting) return [false, ['Token is already approved.']] + return [ + !analysis.isExisting && analysis.isPassingRequirements, + analysis?.reasoning, + ] + }, [analysis]) useEffect(() => { if (isTokenError) @@ -130,13 +272,13 @@ export default function Partner() { }) const onSubmit = (values: ApplyForTokenListTokenSchemaType) => { - if (token?.symbol) { - mutate({ - ...values, - tokenName: token.name, - tokenDecimals: token.decimals, - tokenSymbol: token.symbol, - }) + if (analysis?.token.symbol) { + // mutate({ + // ...values, + // tokenName: analysis.token.name, + // tokenDecimals: analysis.token.decimals, + // tokenSymbol: analysis.token.symbol, + // }) } } @@ -166,12 +308,9 @@ export default function Partner() { control={methods.control} name="chainId" render={({ field: { onChange, value } }) => ( - - + + -
-
- - The network your token is deployed on. -
)} /> @@ -204,10 +339,8 @@ export default function Partner() { name="tokenAddress" render={({ field: { onChange, value, onBlur, name } }) => { return ( - - + + @@ -227,11 +360,39 @@ export default function Partner() { ) }} /> - + + + +
+

Token Status

+ {analysis ? ( + isValid ? ( + + ) : ( + + ) + ) : ( + <> + )} +
+ +
    + {reasoning.map((reason, i) => ( +
  • {reason}
  • + ))} +
+
+ - +
Successfully send your whitelisting request! View your request{' '} - + here ) : null}
- + + {Chain.from(value)?.name} + + + )} /> { return ( @@ -349,7 +332,7 @@ export default function Partner() { value={value} name={name} onBlur={onBlur} - testdata-id="tokenAddress" + testdata-id="address" unit={analysis?.token.symbol} /> @@ -363,25 +346,25 @@ export default function Partner() { -
-

Token Status

- {analysis ? ( - isValid ? ( - +
+

Token Status

+ {analysis ? ( + isValid ? ( + + ) : ( + + ) ) : ( - - ) - ) : ( - <> - )} + <> + )}
    @@ -391,35 +374,47 @@ export default function Partner() {
- - - -
-
- -
- + + { + return ( + +
+ + + + + + Logo URL of your token.
- {logoFile ? ( - icon - ) : null} -
-
- - - Allowed formats: .jpeg, .jpg
- Minimum dimensions are 128x128. -
- +
+ + + {logoUrl ? ( + logo + ) : ( + + )} +
+ + ) + }} + /> {status === 'error' ? ( Oops! Something went wrong when trying to execute your diff --git a/apps/web/src/app/token-listing/schema.ts b/apps/web/src/app/token-listing/schema.ts index e293907015..1dee109481 100644 --- a/apps/web/src/app/token-listing/schema.ts +++ b/apps/web/src/app/token-listing/schema.ts @@ -7,14 +7,15 @@ const ZpdAddress = z .refine((val) => (val ? isAddress(val) : false), 'Invalid address') export const ApplyForTokenListTokenSchema = z.object({ - tokenAddress: ZpdAddress.transform( - (tokenAddress) => getAddress(tokenAddress), + address: ZpdAddress.transform( + (address) => getAddress(address), ), chainId: z.coerce .number() .transform((chainId) => chainId as ChainId) .default(ChainId.ETHEREUM), - logoFile: z.string(), + logoUrl: z.string().url(), + tweetUrl: z.string().url(), }) export type ApplyForTokenListTokenSchemaType = z.infer< From ec57008f692c9801d19cd8cc762436c96fba64b0 Mon Sep 17 00:00:00 2001 From: Ola Stenberg Date: Thu, 19 Sep 2024 18:15:43 +0200 Subject: [PATCH 04/12] feat: add pending tokens page --- apps/web/src/app/token-listing/page.tsx | 3 +- .../src/app/token-listing/pending/page.tsx | 133 ++++++++++++++++++ apps/web/src/lib/hooks/api/index.ts | 1 + .../lib/hooks/api/usePendingTokenListings.ts | 14 ++ .../web/src/lib/hooks/api/useTokenAnalysis.ts | 2 +- .../queries/token-list-submission/index.ts | 1 + .../token-list-submission/pending-tokens.ts | 78 ++++++++++ 7 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/app/token-listing/pending/page.tsx create mode 100644 apps/web/src/lib/hooks/api/usePendingTokenListings.ts create mode 100644 packages/graph-client/src/subgraphs/data-api/queries/token-list-submission/pending-tokens.ts diff --git a/apps/web/src/app/token-listing/page.tsx b/apps/web/src/app/token-listing/page.tsx index 828a72d8f1..ed95a746c4 100644 --- a/apps/web/src/app/token-listing/page.tsx +++ b/apps/web/src/app/token-listing/page.tsx @@ -39,7 +39,6 @@ import { ApplyForTokenListTokenSchema, ApplyForTokenListTokenSchemaType, } from './schema' -import { createSuccessToast, createToast } from '@sushiswap/notifications' const Metrics = ({ analysis, @@ -194,7 +193,7 @@ const Metrics = ({ ) } -export default function Partner() { +export default function TokenListing() { const methods = useForm({ mode: 'all', resolver: zodResolver(ApplyForTokenListTokenSchema), diff --git a/apps/web/src/app/token-listing/pending/page.tsx b/apps/web/src/app/token-listing/pending/page.tsx new file mode 100644 index 0000000000..7435a76fa3 --- /dev/null +++ b/apps/web/src/app/token-listing/pending/page.tsx @@ -0,0 +1,133 @@ +import { + PendingTokens, + getPendingTokens, +} from '@sushiswap/graph-client/data-api/queries/token-list-submission' +import { Container, typographyVariants } from '@sushiswap/ui' +import { unstable_cache } from 'next/cache' +import { formatUSD } from 'sushi' + +export const XIcon = () => ( + + + + + +) +export default async function PendingTokenListingPage() { + const pendingTokens = (await unstable_cache( + async () => await getPendingTokens(), + ['pending-tokens'], + { + revalidate: 60, + }, + )()) as PendingTokens + return ( + <> +
+
+

+ Pending List +

+

+ Tokens not approved will be deleted after 7 days. You may resubmit later if there is meaningful progress. Approvals are not guaranteed. +

+
+
+
+ +
+ {/* Header Row */} +
+ {[ + 'Token', + 'Tweet', + 'Market Cap.', + 'Daily Volume', + 'In Queue', + 'Holders', + ].map((label) => ( +
+ + {label} + +
+ ))} +
+ + {/* Data Rows */} + {pendingTokens.length ? pendingTokens.map((item, index) => ( +
+
+ Token Logo +
+ {item.token.symbol} + + {item.token.name} + +
+
+
+ + + {/* {item.tweetUrl ? 'Yes' : 'No'} */} + +
+
+ + {formatUSD(item.metrics.marketcapUSD)} + +
+
+ + {formatUSD(item.metrics.volumeUSD24h)} + +
+
+ + {Math.floor( + (new Date().getTime() - new Date(item.createdAt * 1000).getTime()) / + (1000 * 60 * 60 * 24), + )}{' '} + Days + +
+
+ {item.metrics.holders} +
+
+ )): ( +
+ No pending tokens +
+ )} +
+
+
+ + ) +} diff --git a/apps/web/src/lib/hooks/api/index.ts b/apps/web/src/lib/hooks/api/index.ts index c1e2bac20f..ff303982e1 100644 --- a/apps/web/src/lib/hooks/api/index.ts +++ b/apps/web/src/lib/hooks/api/index.ts @@ -7,3 +7,4 @@ export * from './useSushiV2UserPositions' export * from './useV2Pool' export * from './useVault' export * from './useTokenAnalysis' +export * from './usePendingTokenListings' diff --git a/apps/web/src/lib/hooks/api/usePendingTokenListings.ts b/apps/web/src/lib/hooks/api/usePendingTokenListings.ts new file mode 100644 index 0000000000..6676ea7fe3 --- /dev/null +++ b/apps/web/src/lib/hooks/api/usePendingTokenListings.ts @@ -0,0 +1,14 @@ +'use client' + +import { getPendingTokens, PendingTokens } from '@sushiswap/graph-client/data-api/queries/token-list-submission' +import { useQuery } from '@tanstack/react-query' + +export function usePendingTokens( + shouldFetch = true, +) { + return useQuery({ + queryKey: ['pending-tokens'], + queryFn: async () => await getPendingTokens(), + enabled: Boolean(shouldFetch), + }) +} \ No newline at end of file diff --git a/apps/web/src/lib/hooks/api/useTokenAnalysis.ts b/apps/web/src/lib/hooks/api/useTokenAnalysis.ts index f03de7ed26..56ad115dd4 100644 --- a/apps/web/src/lib/hooks/api/useTokenAnalysis.ts +++ b/apps/web/src/lib/hooks/api/useTokenAnalysis.ts @@ -12,4 +12,4 @@ export function useTokenAnalysis( queryFn: async () => await getTokenAnalysis(args as GetTokenAnalysis), enabled: Boolean(shouldFetch && args.chainId && args.address), }) -} +} \ No newline at end of file diff --git a/packages/graph-client/src/subgraphs/data-api/queries/token-list-submission/index.ts b/packages/graph-client/src/subgraphs/data-api/queries/token-list-submission/index.ts index a0ae80677a..2af80d5658 100644 --- a/packages/graph-client/src/subgraphs/data-api/queries/token-list-submission/index.ts +++ b/packages/graph-client/src/subgraphs/data-api/queries/token-list-submission/index.ts @@ -1 +1,2 @@ export * from './token-analysis' +export * from './pending-tokens' diff --git a/packages/graph-client/src/subgraphs/data-api/queries/token-list-submission/pending-tokens.ts b/packages/graph-client/src/subgraphs/data-api/queries/token-list-submission/pending-tokens.ts new file mode 100644 index 0000000000..4b94016627 --- /dev/null +++ b/packages/graph-client/src/subgraphs/data-api/queries/token-list-submission/pending-tokens.ts @@ -0,0 +1,78 @@ +import { SUSHI_DATA_API_HOST } from 'sushi/config/subgraph' +import { graphql } from '../../graphql' +import { SUSHI_REQUEST_HEADERS } from '../../request-headers' +import { request } from 'src/lib/request' + +export const PendingTokensQuery = graphql( + ` + query PendingTokens { + pendingTokens { + token { + id + chainId + address + name + symbol + decimals + } + tweetUrl + logoUrl + reasoning + createdAt + metrics { + age + volumeUSD24h + marketcapUSD + holders + } + security { + isOpenSource + isProxy + isMintable + canTakeBackOwnership + ownerChangeBalance + hiddenOwner + selfDestruct + externalCall + gasAbuse + buyTax + sellTax + cannotBuy + cannotSellAll + slippageModifiable + isHoneypot + transferPausable + isBlacklisted + isWhitelisted + isAntiWhale + tradingCooldown + isTrueToken + isAirdropScam + trustList + isBuyable + isFakeToken + isSellLimit + holderCount + } + } +} +`, +) + +export async function getPendingTokens() { + const url = `https://${SUSHI_DATA_API_HOST}` + + const result = await request({ + url, + document: PendingTokensQuery, + requestHeaders: SUSHI_REQUEST_HEADERS, + }) + + if (result) { + return result.pendingTokens + } + + throw new Error('No pendings found') +} + +export type PendingTokens = Awaited> From 6b541245b5f92325983e092eef39b862002cad6e Mon Sep 17 00:00:00 2001 From: Ola Stenberg Date: Thu, 19 Sep 2024 19:17:27 +0200 Subject: [PATCH 05/12] feat: add approved tokens page --- .../src/app/token-listing/approved/page.tsx | 128 ++++++++++++++++++ .../approved-community-tokens.ts | 40 ++++++ .../queries/token-list-submission/index.ts | 1 + .../src/subgraphs/data-api/schema.graphql | 43 ++---- 4 files changed, 180 insertions(+), 32 deletions(-) create mode 100644 apps/web/src/app/token-listing/approved/page.tsx create mode 100644 packages/graph-client/src/subgraphs/data-api/queries/token-list-submission/approved-community-tokens.ts diff --git a/apps/web/src/app/token-listing/approved/page.tsx b/apps/web/src/app/token-listing/approved/page.tsx new file mode 100644 index 0000000000..03a7d3d1ca --- /dev/null +++ b/apps/web/src/app/token-listing/approved/page.tsx @@ -0,0 +1,128 @@ +import { ArrowTopRightOnSquareIcon } from '@heroicons/react/20/solid' +import { + ApprovedCommunityTokens, + getApprovedCommunityTokens, +} from '@sushiswap/graph-client/data-api/queries/token-list-submission' +import { + Button, + Container, + LinkExternal, + typographyVariants, +} from '@sushiswap/ui' +import { NetworkIcon } from '@sushiswap/ui/icons/NetworkIcon' +import { unstable_cache } from 'next/cache' +import { Chain } from 'sushi' + +export default async function ApprovedTokensPage() { + const approvedTokens = (await unstable_cache( + async () => await getApprovedCommunityTokens(), + ['approved-community-tokens'], + { + revalidate: 60, + }, + )()) as ApprovedCommunityTokens + return ( + <> +
+
+

+ Approved List +

+

+ Approved community tokens. +

+
+
+ +
+ +
+ {/* Header Row */} +
+ {['', 'Token', 'Symbol', 'Address'].map((label) => ( +
+ {' '} + {/* Left-aligned text */} + + {label} + +
+ ))} +
+ + {/* Data Rows */} + {approvedTokens.length ? ( + approvedTokens.map((token, index) => ( +
+
+
+ {token.address} +
+ +
+
+
+ +
+ {token.name} +
+ +
+ {token.symbol} +
+ +
+ + + + + +
+
+ )) + ) : ( +
No approved tokens
+ )} +
+
+
+ + ) +} diff --git a/packages/graph-client/src/subgraphs/data-api/queries/token-list-submission/approved-community-tokens.ts b/packages/graph-client/src/subgraphs/data-api/queries/token-list-submission/approved-community-tokens.ts new file mode 100644 index 0000000000..c9e39fdad0 --- /dev/null +++ b/packages/graph-client/src/subgraphs/data-api/queries/token-list-submission/approved-community-tokens.ts @@ -0,0 +1,40 @@ +import { SUSHI_DATA_API_HOST } from 'sushi/config/subgraph' +import { graphql } from '../../graphql' +import { SUSHI_REQUEST_HEADERS } from '../../request-headers' +import { request } from 'src/lib/request' + +export const ApprovedCommunityTokensQuery = graphql( + ` + query ApprovedCommunityTokens { + approvedCommunityTokens { + address + chainId + symbol + name + decimals + approved + logoUrl + } +} +`, +) + +export async function getApprovedCommunityTokens() { + const url = `https://${SUSHI_DATA_API_HOST}` + + const result = await request({ + url, + document: ApprovedCommunityTokensQuery, + requestHeaders: SUSHI_REQUEST_HEADERS, + }) + + if (result) { + return result.approvedCommunityTokens + } + + throw new Error('No Approved Community Tokens found') +} + +export type ApprovedCommunityTokens = Awaited< + ReturnType +> diff --git a/packages/graph-client/src/subgraphs/data-api/queries/token-list-submission/index.ts b/packages/graph-client/src/subgraphs/data-api/queries/token-list-submission/index.ts index 2af80d5658..263b7b7f9e 100644 --- a/packages/graph-client/src/subgraphs/data-api/queries/token-list-submission/index.ts +++ b/packages/graph-client/src/subgraphs/data-api/queries/token-list-submission/index.ts @@ -1,2 +1,3 @@ +export * from './approved-community-tokens' export * from './token-analysis' export * from './pending-tokens' diff --git a/packages/graph-client/src/subgraphs/data-api/schema.graphql b/packages/graph-client/src/subgraphs/data-api/schema.graphql index 55381942b7..32abdaa797 100644 --- a/packages/graph-client/src/subgraphs/data-api/schema.graphql +++ b/packages/graph-client/src/subgraphs/data-api/schema.graphql @@ -20,7 +20,6 @@ type SushiDayBuckets { type Query { sushiDayBuckets(chainId: SushiSwapChainId!): SushiDayBuckets! - protocolFeeAnalysis: ProtocolFeeAnalysis! pools(chainId: PoolChainId!, page: Int = 1, search: [String], protocols: [Protocol], onlyIncentivized: Boolean = false, onlySmartPools: Boolean = false, orderBy: PoolsOrderBy = liquidityUSD, orderDirection: OrderDirection = desc): Pools! topPools(chainId: String!): [TopPool!]! v2Pool(address: Bytes!, chainId: SushiSwapV2ChainId!): V2Pool! @@ -42,9 +41,9 @@ type Query { sushiBarHistory: SushiBarHistory! tokenList(chainId: TokenListChainId!, first: Int = 50, skip: Int, search: String, customTokens: [Bytes!]): [TokenListEntry!]! tokenListBalances(chainId: TokenListChainId!, account: Bytes!, includeNative: Boolean = true, customTokens: [Bytes!]): [TokenListEntryWithBalance!]! - approvedCommunityTokens: [TokenListEntry!]! tokenAnalysis(chainId: Int!, address: Bytes!): TokenAnalysis! pendingTokens: [PendingToken!]! + approvedCommunityTokens: [ApprovedToken!]! trendingTokens(chainId: TrendingTokensChainId!): [TrendingToken!]! v2LiquidityPositions(user: Bytes!, chainId: SushiSwapV2ChainId!): [V2LiquidityPosition!]! v2Swaps(address: Bytes!, chainId: SushiSwapV2ChainId!): [V2Swap!]! @@ -58,36 +57,6 @@ type Query { v3Transactions(address: Bytes!, chainId: SushiSwapV3ChainId!): [V3Transaction!]! } -type NoFeePool { - address: String! - fee: Float! - name: String! - chainId: Int! - token0Address: String! - token1Address: String! - createdAtTimestamp: Int! - volumeUSD1d: Float! - liquidityUSD: Float! -} - -type FeeReport { - totalVolumeUSD1d: Float! - totalLiquidityUSD: Float! - missedRevenueUSD24h: Float! - estimatedAnnualRevenueUSD: Float! -} - -type ProtocolReportByChain { - chainId: Int! - pools: [NoFeePool!]! - report: FeeReport! -} - -type ProtocolFeeAnalysis { - chains: [ProtocolReportByChain!]! - report: FeeReport! -} - enum PoolsOrderBy { liquidityUSD volumeUSD1d @@ -732,6 +701,16 @@ type TokenSecurity { holderCount: Float! } +type ApprovedToken { + address: Bytes! + chainId: ChainId! + symbol: String! + name: String! + decimals: Int! + approved: Boolean! + logoUrl: String +} + type TrendingToken { address: Bytes! symbol: String! From ed3576de6b83da32996f988dda5a6f0d5f2237b3 Mon Sep 17 00:00:00 2001 From: Ola Stenberg Date: Thu, 19 Sep 2024 20:14:53 +0200 Subject: [PATCH 06/12] feat(apps/web): add twitter attestation to token listing --- apps/web/src/app/token-listing/page.tsx | 113 ++++++++++++++--------- apps/web/src/app/token-listing/schema.ts | 2 +- 2 files changed, 71 insertions(+), 44 deletions(-) diff --git a/apps/web/src/app/token-listing/page.tsx b/apps/web/src/app/token-listing/page.tsx index ed95a746c4..d2aa3eace0 100644 --- a/apps/web/src/app/token-listing/page.tsx +++ b/apps/web/src/app/token-listing/page.tsx @@ -41,8 +41,10 @@ import { } from './schema' const Metrics = ({ + isValid, analysis, }: { + isValid: boolean analysis: TokenAnalysis | undefined }) => { return ( @@ -189,26 +191,53 @@ const Metrics = ({
+ +
+

Token Status

+ {analysis ? ( + isValid ? ( + + ) : ( + + ) + ) : ( + <> + )} +
+ +
    + {analysis?.reasoning.map((reason, i) => ( +
  • {reason}
  • + ))} +
+
+ ) } export default function TokenListing() { const methods = useForm({ - mode: 'all', + // mode: 'all', resolver: zodResolver(ApplyForTokenListTokenSchema), defaultValues: { chainId: ChainId.ETHEREUM, - logoUrl: '', - tweetUrl: 'https://x.com/SushiSwap/status/1836208540035486031', + logoUrl: undefined, address: undefined, }, }) - const [chainId, address, logoUrl, tweetUrl] = methods.watch([ + const [chainId, address, logoUrl] = methods.watch([ 'chainId', 'address', 'logoUrl', - 'tweetUrl', ]) const { @@ -220,13 +249,10 @@ export default function TokenListing() { chainId, }) - const [isValid, reasoning] = useMemo(() => { - if (!analysis) return [false, []] - if (analysis.isExisting) return [false, ['Token is already approved.']] - return [ - !analysis.isExisting && analysis.isPassingRequirements, - analysis?.reasoning, - ] + const isValid = useMemo(() => { + if (!analysis) return false + if (analysis.isExisting) return false + return !analysis.isExisting && analysis.isPassingRequirements }, [analysis]) useEffect(() => { @@ -263,7 +289,7 @@ export default function TokenListing() { // TODO: error toast? } } - + return ( <>
@@ -342,37 +368,38 @@ export default function TokenListing() { ) }} /> - + - -
-

Token Status

- {analysis ? ( - isValid ? ( - - ) : ( - - ) - ) : ( - <> - )} -
- -
    - {reasoning.map((reason, i) => ( -
  • {reason}
  • - ))} -
-
- + { + return ( + +
+ + + + + + + Give us a tweet from the project's official Twitter account including the token address. + This is not required, but it increases the chances of getting approved by verifying ownership of the token. + +
+
+ ) + }} + /> chainId as ChainId) .default(ChainId.ETHEREUM), logoUrl: z.string().url(), - tweetUrl: z.string().url(), + tweetUrl: z.string().url().startsWith('https://x.com/').optional(), }) export type ApplyForTokenListTokenSchemaType = z.infer< From e4af8f90d2ee98e40e33f8b3e6c0a68808a4f9f3 Mon Sep 17 00:00:00 2001 From: Ola Stenberg Date: Fri, 20 Sep 2024 10:10:44 +0200 Subject: [PATCH 07/12] fix(apps/web): token listing table fields --- apps/web/src/app/token-listing/page.tsx | 6 +- .../src/app/token-listing/pending/page.tsx | 82 ++++++++++++++----- 2 files changed, 63 insertions(+), 25 deletions(-) diff --git a/apps/web/src/app/token-listing/page.tsx b/apps/web/src/app/token-listing/page.tsx index d2aa3eace0..56b1eb7df1 100644 --- a/apps/web/src/app/token-listing/page.tsx +++ b/apps/web/src/app/token-listing/page.tsx @@ -303,11 +303,11 @@ export default function TokenListing() { className: 'max-w-[800px]', })} > - Get your token verified by Sushi's Community. + Get your token verified to Sushi's Community List.

-
+
@@ -392,7 +392,7 @@ export default function TokenListing() { /> - Give us a tweet from the project's official Twitter account including the token address. + Give us a tweet including the token address from the project's official Twitter account. This is not required, but it increases the chances of getting approved by verifying ownership of the token.
diff --git a/apps/web/src/app/token-listing/pending/page.tsx b/apps/web/src/app/token-listing/pending/page.tsx index 7435a76fa3..f0d3819985 100644 --- a/apps/web/src/app/token-listing/pending/page.tsx +++ b/apps/web/src/app/token-listing/pending/page.tsx @@ -1,10 +1,12 @@ +import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline' import { PendingTokens, getPendingTokens, } from '@sushiswap/graph-client/data-api/queries/token-list-submission' -import { Container, typographyVariants } from '@sushiswap/ui' +import { Button, Container, LinkExternal, typographyVariants } from '@sushiswap/ui' +import { NetworkIcon } from '@sushiswap/ui/icons/NetworkIcon' import { unstable_cache } from 'next/cache' -import { formatUSD } from 'sushi' +import { Chain, formatUSD, shortenAddress } from 'sushi' export const XIcon = () => (
- +
{/* Header Row */} -
+
+
+ + Logo + +
{[ 'Token', 'Tweet', @@ -73,51 +80,82 @@ export default async function PendingTokenListingPage() {
{/* Data Rows */} - {pendingTokens.length ? pendingTokens.map((item, index) => ( + {pendingTokens.length ? pendingTokens.map((pending, index) => (
- Token Logo -
- {item.token.symbol} - - {item.token.name} - +
+
+ {pending.token.address} +
+ +
+
+
- + {pending.token.name} + + ({pending.token.symbol}) + + + + +
+
- {/* {item.tweetUrl ? 'Yes' : 'No'} */} + {pending.tweetUrl ? + : ''}
- {formatUSD(item.metrics.marketcapUSD)} + {formatUSD(pending.metrics.marketcapUSD)}
- {formatUSD(item.metrics.volumeUSD24h)} + {formatUSD(pending.metrics.volumeUSD24h)}
{Math.floor( - (new Date().getTime() - new Date(item.createdAt * 1000).getTime()) / + (new Date().getTime() - new Date(pending.createdAt * 1000).getTime()) / (1000 * 60 * 60 * 24), )}{' '} Days
- {item.metrics.holders} + {pending.metrics.holders}
)): ( From 8b00399bfd1413474366ba4e043930bdf7cb3595 Mon Sep 17 00:00:00 2001 From: Ola Stenberg Date: Fri, 20 Sep 2024 12:38:43 +0200 Subject: [PATCH 08/12] chore(apps/web): remove old token-listing page --- .../app/tokenlist-request/api/submit/route.ts | 289 ------------------ apps/web/src/app/tokenlist-request/layout.tsx | 12 - apps/web/src/app/tokenlist-request/page.tsx | 279 ----------------- apps/web/src/app/tokenlist-request/schema.ts | 33 -- 4 files changed, 613 deletions(-) delete mode 100644 apps/web/src/app/tokenlist-request/api/submit/route.ts delete mode 100644 apps/web/src/app/tokenlist-request/layout.tsx delete mode 100644 apps/web/src/app/tokenlist-request/page.tsx delete mode 100644 apps/web/src/app/tokenlist-request/schema.ts diff --git a/apps/web/src/app/tokenlist-request/api/submit/route.ts b/apps/web/src/app/tokenlist-request/api/submit/route.ts deleted file mode 100644 index 563ca95938..0000000000 --- a/apps/web/src/app/tokenlist-request/api/submit/route.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { createAppAuth } from '@octokit/auth-app' -import { Ratelimit } from '@upstash/ratelimit' -import { NextRequest, NextResponse } from 'next/server' -import { Octokit } from 'octokit' -import { ChainId, ChainKey, chainName } from 'sushi/chain' -import { formatUSD } from 'sushi/format' - -import { rateLimit } from 'src/lib/rate-limit' -import { ApplyForTokenListTokenSchemaType } from '../../schema' - -const owner = 'sushiswap' - -interface ListEntry { - address: string - chainId: number - decimals: number - logoURI: string - name: string - symbol: string -} - -interface MutationParams extends ApplyForTokenListTokenSchemaType { - tokenName?: string - tokenSymbol: string - tokenDecimals: number -} - -export const maxDuration = 15 // in seconds - -export async function POST(request: NextRequest) { - const ratelimit = rateLimit(Ratelimit.slidingWindow(5, '1 h')) - if (ratelimit) { - const { remaining } = await ratelimit.limit( - request.ip || request.headers.get('x-real-ip') || '127.0.0.1', - ) - if (!remaining) { - return NextResponse.json({ error: 'Too many requests' }, { status: 429 }) - } - } - - if (!process.env.TOKEN_LIST_PR_WEBHOOK_URL) - throw new Error('TOKEN_LIST_PR_WEBHOOK_URL undefined') - if (!process.env.OCTOKIT_KEY) throw new Error('OCTOKIT_KEY undefined') - - const { - tokenAddress, - tokenName, - tokenDecimals, - tokenSymbol, - logoFile, - chainId, - listType, - } = (await request.json()) as MutationParams - - const octoKit = new Octokit({ - authStrategy: createAppAuth, - auth: { - appId: 169875, - privateKey: process.env.OCTOKIT_KEY?.replace(/\\n/g, '\n'), - installationId: 23112528, - }, - }) - - // Get latest commit for the new branch - const { - data: { - commit: { sha: latestIconsSha }, - }, - } = await octoKit.request('GET /repos/{owner}/{repo}/branches/{branch}', { - owner, - repo: 'list', - branch: 'master', - }) - - // Filter out characters that github / ... might not like - const displayName = tokenSymbol.toLowerCase().replace(/( )|(\.)/g, '_') - - // Find unused branch name - const branch = await (async () => { - const branches: string[] = [] - - for (let i = 1; ; i++) { - const { data }: { data: { name: string }[] } = await octoKit.request( - 'GET /repos/{owner}/{repo}/branches', - { - owner, - repo: 'list', - per_page: 100, - page: i, - }, - ) - - const newBranches = data.reduce((acc: string[], e: { name: string }) => { - acc.push(e.name) - return acc - }, [] as string[]) - - branches.push(...newBranches) - - if (newBranches.length < 100) break - } - - const createBranchName = (name: string, depth = 0): string => { - if (!branches.includes(name)) return name - else if (!branches.includes(`${name}-${depth}`)) return `${name}-${depth}` - else return createBranchName(name, ++depth) - } - - return createBranchName(displayName) - })() - - // Create new branch - await octoKit.request('POST /repos/{owner}/{repo}/git/refs', { - owner, - repo: 'list', - ref: `refs/heads/${branch}`, - sha: latestIconsSha, - }) - - const imagePath = `logos/token-logos/network/${ChainKey[ - chainId - ].toLowerCase()}/${tokenAddress}.jpg` - - try { - // Figure out if image already exists, overwrite if it does - let previousImageFileSha: string | undefined - - try { - const res = await octoKit.request( - 'GET /repos/{owner}/{repo}/contents/{path}', - { - owner, - repo: 'list', - branch: 'master', - path: imagePath, - }, - ) - - if (!Array.isArray(res.data)) { - previousImageFileSha = res.data.sha - } - } catch { - // - } - - // Upload image - await octoKit.request('PUT /repos/{owner}/{repo}/contents/{path}', { - owner, - repo: 'list', - branch: branch, - path: imagePath, - content: logoFile.split(',')[1], - message: `Upload ${displayName} icon`, - sha: previousImageFileSha, - }) - } catch (_e: unknown) { - return NextResponse.json( - { error: 'Failed to add token image' }, - { status: 500 }, - ) - } - - const listPath = `lists/token-lists/${listType}/tokens/${ChainKey[ - chainId - ].toLowerCase()}.json` - - // Get current token list to append to - let currentListData - - try { - const res = await octoKit.request( - 'GET /repos/{owner}/{repo}/contents/{path}', - { - owner, - repo: 'list', - branch: 'master', - path: listPath, - }, - ) - - if (!Array.isArray(res.data) && res.data.type === 'file') { - currentListData = { sha: res.data.sha, content: res.data.content } - } - } catch { - // - } - - let currentList: ListEntry[] = currentListData - ? JSON.parse( - Buffer.from(currentListData?.content, 'base64').toString('ascii'), - ) - : [] - - // Remove from current list if exists to overwrite later - currentList = currentList.filter((entry) => entry.address !== tokenAddress) - - // Append to current list - const newList = [ - ...currentList, - { - address: tokenAddress, - chainId: chainId, - decimals: Number(tokenDecimals), - logoURI: `https://raw.githubusercontent.com/${owner}/list/master/${imagePath}`, - name: tokenName, - symbol: tokenSymbol, - }, - ].sort((a, b) => a.symbol.localeCompare(b.symbol)) - - // Upload new list - await octoKit.request('PUT /repos/{owner}/{repo}/contents/{path}', { - owner, - repo: 'list', - branch: branch, - path: listPath, - content: Buffer.from(JSON.stringify(newList, null, 2)).toString('base64'), - message: `Add ${displayName} on ${chainName[chainId].toLowerCase()}`, - sha: currentListData?.sha, - }) - - // Open List PR - const { - data: { html_url: listPr }, - } = await octoKit.request('POST /repos/{owner}/{repo}/pulls', { - owner, - repo: 'list', - title: `Token: ${displayName}`, - head: branch, - base: 'master', - body: `Chain: ${chainName[chainId] ?? chainId} - Name: ${tokenName} - Symbol: ${tokenSymbol} - Decimals: ${tokenDecimals} - List: ${listType} - Volume: ${formatUSD(0)} - Liquidity: ${formatUSD(0)} - CoinGecko: ${await getCoinGecko(chainId, tokenAddress)} - Image: https://github.com/${owner}/list/tree/${branch}/${imagePath} - ![${displayName}](https://raw.githubusercontent.com/${owner}/list/${branch}/${imagePath}) - `, - }) - - // Send Discord notification using webhook - await fetch(process.env.TOKEN_LIST_PR_WEBHOOK_URL, { - method: 'POST', - body: JSON.stringify({ - content: null, - embeds: [ - { - description: 'New pull request', - color: 5814783, - author: { - name: `${tokenName} - ${chainName[chainId]}`, - url: listPr, - icon_url: `https://raw.githubusercontent.com/${owner}/list/${branch}/${imagePath}`, - }, - }, - ], - username: 'GitHub List Repo', - avatar_url: - 'https://banner2.cleanpng.com/20180824/jtl/kisspng-computer-icons-logo-portable-network-graphics-clip-icons-for-free-iconza-circle-social-5b7fe46b0bac53.1999041115351082030478.jpg', - }), - headers: { 'Content-Type': 'application/json' }, - }) - - return NextResponse.json( - { listPr }, - { - status: 200, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST', - 'Access-Control-Allow-Headers': 'Content-Type', - }, - }, - ) -} - -async function getCoinGecko(chainId: ChainId, address: string) { - return await fetch( - `https://api.coingecko.com/api/v3/coins/${chainName[ - chainId - ].toLowerCase()}/contract/${address}`, - ) - .then((data) => data.json()) - .then((data) => - data.id ? `https://www.coingecko.com/en/coins/${data.id}` : 'Not Found', - ) -} diff --git a/apps/web/src/app/tokenlist-request/layout.tsx b/apps/web/src/app/tokenlist-request/layout.tsx deleted file mode 100644 index 338a92c96b..0000000000 --- a/apps/web/src/app/tokenlist-request/layout.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react' - -import { QueryClientProvider } from '../../providers/query-client-provider' -import { WagmiProvider } from '../../providers/wagmi-provider' - -export default function Layout({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ) -} diff --git a/apps/web/src/app/tokenlist-request/page.tsx b/apps/web/src/app/tokenlist-request/page.tsx deleted file mode 100644 index c395a4d1f7..0000000000 --- a/apps/web/src/app/tokenlist-request/page.tsx +++ /dev/null @@ -1,279 +0,0 @@ -'use client' - -import { CameraIcon } from '@heroicons/react/24/outline' -import { zodResolver } from '@hookform/resolvers/zod' -import { useApplyForTokenList } from '@sushiswap/react-query' -import { - Container, - Form, - FormControl, - FormField, - FormItem, - FormMessage, - Label, - LinkExternal, - Message, - NetworkSelector, - SelectIcon, - Separator, - TextField, - typographyVariants, -} from '@sushiswap/ui' -import { Button } from '@sushiswap/ui' -import { NetworkIcon } from '@sushiswap/ui/icons/NetworkIcon' -import React, { useCallback, useEffect } from 'react' -import { DropzoneOptions, useDropzone } from 'react-dropzone' -import { useForm } from 'react-hook-form' -import { Chain, ChainId } from 'sushi/chain' -import { type Address, isAddress } from 'viem' - -import { useTokenWithCache } from 'src/lib/wagmi/hooks/tokens/useTokenWithCache' -import { SUPPORTED_CHAIN_IDS } from '../../config' -import { - ApplyForTokenListListType, - ApplyForTokenListTokenSchema, - ApplyForTokenListTokenSchemaType, -} from './schema' - -export default function Partner() { - const methods = useForm({ - mode: 'all', - resolver: zodResolver(ApplyForTokenListTokenSchema), - defaultValues: { - chainId: ChainId.ETHEREUM, - listType: ApplyForTokenListListType.DEFAULT, - logoFile: '', - tokenAddress: '', - }, - }) - - const [chainId, tokenAddress, logoFile] = methods.watch([ - 'chainId', - 'tokenAddress', - 'logoFile', - ]) - - const { data: token, isError: isTokenError } = useTokenWithCache({ - address: tokenAddress as Address, - chainId, - enabled: isAddress(tokenAddress, { strict: false }), - }) - const { mutate, isPending, data, status } = useApplyForTokenList() - - useEffect(() => { - if (isTokenError) - methods.setError('tokenAddress', { - type: 'custom', - message: 'Token not found', - }) - else methods.clearErrors('tokenAddress') - }, [methods, isTokenError]) - - const onDrop = useCallback>( - (acceptedFiles) => { - acceptedFiles.forEach((file) => { - const reader = new FileReader() - reader.readAsDataURL(file) - reader.addEventListener('load', () => { - if (reader.result) { - const imageAsBase64 = reader.result.toString() - const image = document.createElement('img') - image.src = imageAsBase64 - image.onload = () => { - const canvas = document.createElement('canvas') - canvas.width = 128 - canvas.height = 128 - const context = canvas.getContext('2d', { alpha: false }) - if (context) { - context.drawImage(image, 0, 0, canvas.width, canvas.height) - const resizedImageAsBase64 = canvas.toDataURL('image/jpeg') - methods.setValue('logoFile', resizedImageAsBase64) - } - } - } - }) - }) - }, - [methods], - ) - - const { getRootProps, getInputProps } = useDropzone({ - onDrop, - accept: { 'image/jpeg': ['.jpeg', '.jpg'] }, - maxFiles: 1, - }) - - const onSubmit = (values: ApplyForTokenListTokenSchemaType) => { - if (token?.symbol) { - mutate({ - ...values, - tokenName: token.name, - tokenDecimals: token.decimals, - tokenSymbol: token.symbol, - }) - } - } - - return ( - -
-
-

- Get on our
default token list -

-

- Join us in our mission to revolutionize decentralized finance while - building trust and credibility. -

-
-
-
- -
-
-

Create your request

-

- Kindly complete the provided form; this action will initiate the - creation of a pull request on our GitHub repository. For your - convenience, you can track the progress and updates{' '} - - there - - . Thank you for your participation. -

- - -
- - - -
-
- -
- -
- {logoFile ? ( - icon - ) : null} -
-
-
- - Allowed formats: .jpeg, .jpg
- Minimum dimensions are 128x128. -
-
- ( - - - -
- - - -
-
- - The network your token is deployed on. - -
- )} - /> - { - return ( - - - - - - - The contract address of your token. - - - ) - }} - /> - {status === 'error' ? ( - - Oops! Something went wrong when trying to execute your - request. - - ) : null} - {status === 'success' ? ( - - Successfully send your whitelisting request! View your request{' '} - - here - - - ) : null} -
- -
-
- - -
-
- ) -} diff --git a/apps/web/src/app/tokenlist-request/schema.ts b/apps/web/src/app/tokenlist-request/schema.ts deleted file mode 100644 index b5d66cb2ec..0000000000 --- a/apps/web/src/app/tokenlist-request/schema.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ChainId } from 'sushi/chain' -import { getAddress, isAddress } from 'viem' -import { z } from 'zod' - -export enum ApplyForTokenListListType { - DEFAULT = 'default-token-list', - COMMUNITY = 'community-token-list', -} - -const ZpdAddress = z - .string() - .refine((val) => (val ? isAddress(val) : false), 'Invalid address') - -export const ApplyForTokenListTokenSchema = z.object({ - tokenAddress: ZpdAddress.transform( - (tokenAddress) => getAddress(tokenAddress) as string, - ), - chainId: z.coerce - .number() - .transform((chainId) => chainId as ChainId) - .default(ChainId.ETHEREUM), - listType: z - .enum([ - ApplyForTokenListListType.DEFAULT, - ApplyForTokenListListType.COMMUNITY, - ]) - .default(ApplyForTokenListListType.DEFAULT), - logoFile: z.string(), -}) - -export type ApplyForTokenListTokenSchemaType = z.infer< - typeof ApplyForTokenListTokenSchema -> From c5ea33214bda6b4c813474ade77b1731c36e9fec Mon Sep 17 00:00:00 2001 From: Ola Stenberg Date: Fri, 20 Sep 2024 13:35:27 +0200 Subject: [PATCH 09/12] feat(apps/web): add bread crumb, navbar, listing requirement info --- .../src/app/token-listing/pending/page.tsx | 171 ---------------- .../approved/page.tsx | 18 +- apps/web/src/app/tokenlist-request/header.tsx | 37 ++++ .../layout.tsx | 2 + .../page.tsx | 23 ++- .../app/tokenlist-request/pending/page.tsx | 191 ++++++++++++++++++ .../schema.ts | 0 7 files changed, 265 insertions(+), 177 deletions(-) delete mode 100644 apps/web/src/app/token-listing/pending/page.tsx rename apps/web/src/app/{token-listing => tokenlist-request}/approved/page.tsx (90%) create mode 100644 apps/web/src/app/tokenlist-request/header.tsx rename apps/web/src/app/{token-listing => tokenlist-request}/layout.tsx (87%) rename apps/web/src/app/{token-listing => tokenlist-request}/page.tsx (93%) create mode 100644 apps/web/src/app/tokenlist-request/pending/page.tsx rename apps/web/src/app/{token-listing => tokenlist-request}/schema.ts (100%) diff --git a/apps/web/src/app/token-listing/pending/page.tsx b/apps/web/src/app/token-listing/pending/page.tsx deleted file mode 100644 index f0d3819985..0000000000 --- a/apps/web/src/app/token-listing/pending/page.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline' -import { - PendingTokens, - getPendingTokens, -} from '@sushiswap/graph-client/data-api/queries/token-list-submission' -import { Button, Container, LinkExternal, typographyVariants } from '@sushiswap/ui' -import { NetworkIcon } from '@sushiswap/ui/icons/NetworkIcon' -import { unstable_cache } from 'next/cache' -import { Chain, formatUSD, shortenAddress } from 'sushi' - -export const XIcon = () => ( - - - - - -) -export default async function PendingTokenListingPage() { - const pendingTokens = (await unstable_cache( - async () => await getPendingTokens(), - ['pending-tokens'], - { - revalidate: 60, - }, - )()) as PendingTokens - return ( - <> -
-
-

- Pending List -

-

- Tokens not approved will be deleted after 7 days. You may resubmit later if there is meaningful progress. Approvals are not guaranteed. -

-
-
-
- -
- {/* Header Row */} -
-
- - Logo - -
- {[ - 'Token', - 'Tweet', - 'Market Cap.', - 'Daily Volume', - 'In Queue', - 'Holders', - ].map((label) => ( -
- - {label} - -
- ))} -
- - {/* Data Rows */} - {pendingTokens.length ? pendingTokens.map((pending, index) => ( -
-
-
-
- {pending.token.address} -
- -
-
-
- -
-
- {pending.token.name} - - ({pending.token.symbol}) - - - - -
-
- - {pending.tweetUrl ? - : ''} - -
-
- - {formatUSD(pending.metrics.marketcapUSD)} - -
-
- - {formatUSD(pending.metrics.volumeUSD24h)} - -
-
- - {Math.floor( - (new Date().getTime() - new Date(pending.createdAt * 1000).getTime()) / - (1000 * 60 * 60 * 24), - )}{' '} - Days - -
-
- {pending.metrics.holders} -
-
- )): ( -
- No pending tokens -
- )} -
-
-
- - ) -} diff --git a/apps/web/src/app/token-listing/approved/page.tsx b/apps/web/src/app/tokenlist-request/approved/page.tsx similarity index 90% rename from apps/web/src/app/token-listing/approved/page.tsx rename to apps/web/src/app/tokenlist-request/approved/page.tsx index 03a7d3d1ca..16321c92ad 100644 --- a/apps/web/src/app/token-listing/approved/page.tsx +++ b/apps/web/src/app/tokenlist-request/approved/page.tsx @@ -7,6 +7,7 @@ import { Button, Container, LinkExternal, + LinkInternal, typographyVariants, } from '@sushiswap/ui' import { NetworkIcon } from '@sushiswap/ui/icons/NetworkIcon' @@ -23,8 +24,16 @@ export default async function ApprovedTokensPage() { )()) as ApprovedCommunityTokens return ( <> -
-
+
+
+ + ← Token Listing + +
+

Approved List

@@ -40,7 +49,10 @@ export default async function ApprovedTokensPage() {
- + +
+

Last 25 Approved Tokens

+
{/* Header Row */}
+} diff --git a/apps/web/src/app/token-listing/layout.tsx b/apps/web/src/app/tokenlist-request/layout.tsx similarity index 87% rename from apps/web/src/app/token-listing/layout.tsx rename to apps/web/src/app/tokenlist-request/layout.tsx index 338a92c96b..19bbb4d082 100644 --- a/apps/web/src/app/token-listing/layout.tsx +++ b/apps/web/src/app/tokenlist-request/layout.tsx @@ -2,10 +2,12 @@ import React from 'react' import { QueryClientProvider } from '../../providers/query-client-provider' import { WagmiProvider } from '../../providers/wagmi-provider' +import { Header } from './header' export default function Layout({ children }: { children: React.ReactNode }) { return ( +
{children} ) diff --git a/apps/web/src/app/token-listing/page.tsx b/apps/web/src/app/tokenlist-request/page.tsx similarity index 93% rename from apps/web/src/app/token-listing/page.tsx rename to apps/web/src/app/tokenlist-request/page.tsx index 56b1eb7df1..e383d4aa9e 100644 --- a/apps/web/src/app/token-listing/page.tsx +++ b/apps/web/src/app/tokenlist-request/page.tsx @@ -289,7 +289,7 @@ export default function TokenListing() { // TODO: error toast? } } - + return ( <>
@@ -392,8 +392,10 @@ export default function TokenListing() { /> - Give us a tweet including the token address from the project's official Twitter account. - This is not required, but it increases the chances of getting approved by verifying ownership of the token. + Give us a tweet including the token address from the + project's official Twitter account. This is not + required, but it increases the chances of getting + approved by verifying ownership of the token.
@@ -472,6 +474,21 @@ export default function TokenListing() {
+
+

+ * You can submit your token once it meets the required threshold + values. These values may be different depending on the network you have selected. While there is an age requirement based on the + token's first tradable date (not the creation date) to help + reduce noise and maintain quality, exceptions may be made for + well-known tokens within the community. +

+

+ If your coin is listed on CoinGecko, it automatically bypasses all + other requirements and can be submitted regardless. For notable + pre-launch tokens, feel free to reach out in the token-list + channel on Discord with your token address! +

+
diff --git a/apps/web/src/app/tokenlist-request/pending/page.tsx b/apps/web/src/app/tokenlist-request/pending/page.tsx new file mode 100644 index 0000000000..f3cd30afe1 --- /dev/null +++ b/apps/web/src/app/tokenlist-request/pending/page.tsx @@ -0,0 +1,191 @@ +import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline' +import { + PendingTokens, + getPendingTokens, +} from '@sushiswap/graph-client/data-api/queries/token-list-submission' +import { + Button, + Container, + LinkExternal, + LinkInternal, + typographyVariants, +} from '@sushiswap/ui' +import { NetworkIcon } from '@sushiswap/ui/icons/NetworkIcon' +import { unstable_cache } from 'next/cache' +import { Chain, formatUSD, shortenAddress } from 'sushi' + +export const XIcon = () => ( + + + + + +) +export default async function PendingTokenListingPage() { + const pendingTokens = (await unstable_cache( + async () => await getPendingTokens(), + ['pending-tokens'], + { + revalidate: 60, + }, + )()) as PendingTokens + return ( + <> +
+
+ + ← Token Listing + +
+
+

+ Pending List +

+

+ Tokens not approved will be deleted after 7 days. You may resubmit + later if there is meaningful progress. Approvals are not guaranteed. +

+
+
+
+ +
+ {/* Header Row */} +
+
+ Logo +
+ {[ + 'Token', + 'Tweet', + 'Market Cap.', + 'Daily Volume', + 'In Queue', + 'Holders', + ].map((label) => ( +
+ + {label} + +
+ ))} +
+ + {/* Data Rows */} + {pendingTokens.length ? ( + pendingTokens.map((pending, index) => ( +
+
+
+
+ {pending.token.address} +
+ +
+
+
+
+
+ {pending.token.name} + + ({pending.token.symbol}) + + + + +
+
+ + {pending.tweetUrl ? ( + + + + ) : ( + '' + )} + +
+
+ + {formatUSD(pending.metrics.marketcapUSD)} + +
+
+ + {formatUSD(pending.metrics.volumeUSD24h)} + +
+
+ + {Math.floor( + (new Date().getTime() - + new Date(pending.createdAt * 1000).getTime()) / + (1000 * 60 * 60 * 24), + )}{' '} + Days + +
+
+ {pending.metrics.holders} +
+
+ )) + ) : ( +
+ No pending tokens +
+ )} +
+
+
+ + ) +} diff --git a/apps/web/src/app/token-listing/schema.ts b/apps/web/src/app/tokenlist-request/schema.ts similarity index 100% rename from apps/web/src/app/token-listing/schema.ts rename to apps/web/src/app/tokenlist-request/schema.ts From 7c31b3f2b54749a8a69a793e214bf3b4e2672d10 Mon Sep 17 00:00:00 2001 From: 0xMasayoshi <0xMasayoshi@protonmail.com> Date: Wed, 2 Oct 2024 04:55:40 +0800 Subject: [PATCH 10/12] fix: APRWithRewardsHoverCard --- apps/web/src/ui/pool/APRWithRewardsHoverCard.tsx | 10 ++++++++-- apps/web/src/ui/pool/columns.tsx | 1 + 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/web/src/ui/pool/APRWithRewardsHoverCard.tsx b/apps/web/src/ui/pool/APRWithRewardsHoverCard.tsx index 8cc76bd790..3f7802e985 100644 --- a/apps/web/src/ui/pool/APRWithRewardsHoverCard.tsx +++ b/apps/web/src/ui/pool/APRWithRewardsHoverCard.tsx @@ -104,7 +104,9 @@ export const APRWithRewardsHoverCard: FC = ({ <>
- {children} + + {children} + {card} @@ -112,7 +114,11 @@ export const APRWithRewardsHoverCard: FC = ({
- e.stopPropagation()} asChild> + e.stopPropagation()} + asChild + className="cursor-pointer" + > {children} diff --git a/apps/web/src/ui/pool/columns.tsx b/apps/web/src/ui/pool/columns.tsx index 086c956968..abe18d3ec1 100644 --- a/apps/web/src/ui/pool/columns.tsx +++ b/apps/web/src/ui/pool/columns.tsx @@ -413,6 +413,7 @@ export const APR_WITH_REWARDS_COLUMN: ColumnDef = { ), meta: { skeleton: , + disableLink: true, }, } From 588d61ecd12fcae247a94077be41c069b0eac3a8 Mon Sep 17 00:00:00 2001 From: 0xMasayoshi <0xMasayoshi@protonmail.com> Date: Wed, 2 Oct 2024 04:57:20 +0800 Subject: [PATCH 11/12] chore: polish token request pages --- .../app/tokenlist-request/approved/page.tsx | 251 ++++---- apps/web/src/app/tokenlist-request/header.tsx | 49 +- apps/web/src/app/tokenlist-request/layout.tsx | 15 +- .../tokenlist-request/navigation-items.tsx | 47 ++ apps/web/src/app/tokenlist-request/page.tsx | 550 +++++++++--------- .../app/tokenlist-request/pending/page.tsx | 459 +++++++++------ .../src/app/tokenlist-request/providers.tsx | 22 + apps/web/src/lib/hooks/api/index.ts | 1 + .../hooks/api/useApprovedCommunityTokens.ts | 15 + .../lib/hooks/api/usePendingTokenListings.ts | 11 +- .../web/src/lib/hooks/api/useTokenAnalysis.ts | 16 +- .../wagmi/components/token-security-view.tsx | 4 +- packages/ui/src/icons/XIcon.tsx | 20 + 13 files changed, 849 insertions(+), 611 deletions(-) create mode 100644 apps/web/src/app/tokenlist-request/navigation-items.tsx create mode 100644 apps/web/src/app/tokenlist-request/providers.tsx create mode 100644 apps/web/src/lib/hooks/api/useApprovedCommunityTokens.ts create mode 100644 packages/ui/src/icons/XIcon.tsx diff --git a/apps/web/src/app/tokenlist-request/approved/page.tsx b/apps/web/src/app/tokenlist-request/approved/page.tsx index 504af588de..acb332168c 100644 --- a/apps/web/src/app/tokenlist-request/approved/page.tsx +++ b/apps/web/src/app/tokenlist-request/approved/page.tsx @@ -1,139 +1,142 @@ -import { ArrowTopRightOnSquareIcon } from '@heroicons/react/20/solid' -import { - ApprovedCommunityTokens, - getApprovedCommunityTokens, -} from '@sushiswap/graph-client/data-api/queries/token-list-submission' +'use client' + +import { ExternalLinkIcon } from '@heroicons/react-v1/solid' +import { ApprovedCommunityTokens } from '@sushiswap/graph-client/data-api/queries/token-list-submission' import { - Button, + Badge, + Card, + CardHeader, + CardTitle, Container, + DataTable, LinkExternal, - LinkInternal, - typographyVariants, + SkeletonCircle, + SkeletonText, } from '@sushiswap/ui' import { NetworkIcon } from '@sushiswap/ui/icons/NetworkIcon' -import { unstable_cache } from 'next/cache' -import React from 'react' -import { Chain } from 'sushi' +import { ColumnDef, SortingState, TableState } from '@tanstack/react-table' +import React, { useMemo, useState } from 'react' +import { useApprovedCommunityTokens } from 'src/lib/hooks' +import { Chain } from 'sushi/chain' +import { shortenAddress } from 'sushi/format' +import { NavigationItems } from '../navigation-items' -export default async function ApprovedTokensPage() { - const approvedTokens = (await unstable_cache( - async () => await getApprovedCommunityTokens(), - ['approved-community-tokens'], - { - revalidate: 60, - }, - )()) as ApprovedCommunityTokens - return ( - <> -
-
- - ← Token Listing - -
-
-

- Approved List -

-

- Approved community tokens. -

+const COLUMNS: ColumnDef[] = [ + { + id: 'logo', + header: 'Logo', + cell: (props) => ( + + } + > +
+ {props.row.original.symbol}
+
+ ), + meta: { + skeleton: , + }, + }, + { + id: 'name', + header: 'Name', + accessorFn: (row) => row.name, + cell: (props) => props.row.original.name, + meta: { + skeleton: , + }, + }, + { + id: 'symbol', + header: 'Symbol', + accessorFn: (row) => row.symbol, + cell: (props) => props.row.original.symbol, + meta: { + skeleton: , + }, + }, + { + id: 'address', + header: 'Address', + cell: (props) => ( +
+ + {shortenAddress(props.row.original.address)} + + {props.row.original.address} + + +
+ ), + meta: { + skeleton: , + }, + }, +] -
- -
-

Last 25 Approved Tokens

-
-
- {/* Header Row */} -
- {['', 'Token', 'Symbol', 'Address'].map((label) => ( -
- {' '} - {/* Left-aligned text */} - - {label} - -
- ))} -
- - {/* Data Rows */} - {approvedTokens.length ? ( - approvedTokens.map((token, index) => ( -
-
-
- {token.address} -
- -
-
-
+export default function ApprovedTokensPage() { + const { data, isLoading } = useApprovedCommunityTokens() -
- {token.name} -
+ const [sorting, setSorting] = useState([ + { id: 'createdAt', desc: true }, + ]) -
- {token.symbol} -
+ const state: Partial = useMemo(() => { + return { + sorting, + pagination: { + pageIndex: 0, + pageSize: data?.length ?? 0, + }, + } + }, [data?.length, sorting]) -
- - - - - -
-
- )) - ) : ( -
No approved tokens
- )} -
+ return ( + <> + +

Approved List

+

+ Approved community tokens. +

+
+ +
+ + + + Last 25 Approved Tokens + + +
diff --git a/apps/web/src/app/tokenlist-request/header.tsx b/apps/web/src/app/tokenlist-request/header.tsx index 4f7a8340d0..c3d81de0d5 100644 --- a/apps/web/src/app/tokenlist-request/header.tsx +++ b/apps/web/src/app/tokenlist-request/header.tsx @@ -1,37 +1,18 @@ -import { - Navigation, - NavigationElement, - NavigationElementType, -} from '@sushiswap/ui' -interface HeaderLink { - name: string - href: string - isExternal?: boolean -} - -export interface HeaderSection { - title: string - href?: string - links?: HeaderLink[] - isExternal?: boolean - className?: string -} +'use client' -export async function Header() { - const navData: NavigationElement[] = [ - { - title: 'Pending', - href: '/tokenlist-request/pending', - show: 'everywhere', - type: NavigationElementType.Single, - }, - { - title: 'Approved', - href: '/tokenlist-request/approved', - show: 'everywhere', - type: NavigationElementType.Single, - }, - ] +import { Navigation } from '@sushiswap/ui' +import React, { FC } from 'react' +import { SUPPORTED_CHAIN_IDS } from 'src/config' +import { WagmiHeaderComponents } from 'src/lib/wagmi/components/wagmi-header-components' +import { useChainId } from 'wagmi' +import { headerElements } from '~evm/_common/header-elements' - return +export const Header: FC = () => { + const chainId = useChainId() + return ( + } + /> + ) } diff --git a/apps/web/src/app/tokenlist-request/layout.tsx b/apps/web/src/app/tokenlist-request/layout.tsx index 19bbb4d082..2b0bd3809b 100644 --- a/apps/web/src/app/tokenlist-request/layout.tsx +++ b/apps/web/src/app/tokenlist-request/layout.tsx @@ -1,14 +1,17 @@ import React from 'react' -import { QueryClientProvider } from '../../providers/query-client-provider' -import { WagmiProvider } from '../../providers/wagmi-provider' +import { headers } from 'next/headers' import { Header } from './header' +import { Providers } from './providers' export default function Layout({ children }: { children: React.ReactNode }) { + const cookie = headers().get('cookie') return ( - -
- {children} - + +
+
+ {children} +
+
) } diff --git a/apps/web/src/app/tokenlist-request/navigation-items.tsx b/apps/web/src/app/tokenlist-request/navigation-items.tsx new file mode 100644 index 0000000000..2d95464415 --- /dev/null +++ b/apps/web/src/app/tokenlist-request/navigation-items.tsx @@ -0,0 +1,47 @@ +import { Container, LinkInternal } from '@sushiswap/ui' +import { PathnameButton } from 'src/ui/pathname-button' + +export function NavigationItems() { + return ( + + + + New token + + + + + Pending + + + + + Approved + + + + ) +} diff --git a/apps/web/src/app/tokenlist-request/page.tsx b/apps/web/src/app/tokenlist-request/page.tsx index 46766082fa..b337c057cb 100644 --- a/apps/web/src/app/tokenlist-request/page.tsx +++ b/apps/web/src/app/tokenlist-request/page.tsx @@ -1,7 +1,13 @@ 'use client' +import { + CheckCircleIcon, + EllipsisHorizontalCircleIcon, + ExclamationCircleIcon, +} from '@heroicons/react/20/solid' import { CameraIcon } from '@heroicons/react/24/outline' import { zodResolver } from '@hookform/resolvers/zod' +import { TokenAnalysis } from '@sushiswap/graph-client/data-api/queries/token-list-submission' import { Button, Card, @@ -13,219 +19,231 @@ import { FormMessage, Label, LinkExternal, + Loader, Message, NetworkSelector, SelectIcon, Separator, TextField, classNames, - typographyVariants, } from '@sushiswap/ui' import { NetworkIcon } from '@sushiswap/ui/icons/NetworkIcon' +import { useMutation } from '@tanstack/react-query' import { useEffect, useMemo } from 'react' -import { useForm } from 'react-hook-form' +import { SubmitHandler, useForm } from 'react-hook-form' import { useTokenAnalysis } from 'src/lib/hooks/api/useTokenAnalysis' import { Chain, ChainId } from 'sushi/chain' - -import { - CheckCircleIcon, - ExclamationCircleIcon, -} from '@heroicons/react/20/solid' -import { TokenAnalysis } from '@sushiswap/graph-client/data-api/queries/token-list-submission' +import { SUSHI_DATA_API_HOST } from 'sushi/config/subgraph' import { formatNumber, formatUSD } from 'sushi/format' import { SUPPORTED_CHAIN_IDS } from '../../config' +import { NavigationItems } from './navigation-items' import { ApplyForTokenListTokenSchema, ApplyForTokenListTokenSchemaType, } from './schema' const Metrics = ({ - isValid, analysis, + isValid, + isLoading, + isError, }: { - isValid: boolean analysis: TokenAnalysis | undefined + isValid: boolean + isLoading: boolean + isError: boolean }) => { return ( - <> -

+
+

Metrics

- -
- - Age {analysis ? ` (>${analysis.requirements.minimumAge} Days)` : ''} - - - Daily Volume{' '} - {analysis - ? ` (>${formatUSD(analysis.requirements.minimumVolumeUSD24h)})` - : ''} - - - Market Cap{' '} - {analysis - ? ` (>${formatUSD(analysis.requirements.minimumMarketcapUSD)})` - : ''} - - - Holder Count{' '} - {analysis ? ` (>${analysis.requirements.minimumHolders})` : ''} - -
- -
-
= analysis.requirements.minimumAge - ? 'text-[#139B6D]' - : analysis && - analysis.metrics.age < analysis.requirements.minimumAge - ? 'text-[#B4303C]' - : 'text-muted-foreground', - )} - > - {analysis ? ( - <> - {analysis.metrics.age >= analysis.requirements.minimumAge ? ( - - ) : ( - - )} - {analysis.metrics.age} Days - - ) : ( - '-' - )} -
-
= - analysis.requirements.minimumVolumeUSD24h - ? 'text-[#139B6D]' - : analysis && - analysis.metrics.volumeUSD24h < - analysis.requirements.minimumVolumeUSD24h - ? 'text-[#B4303C]' - : 'text-muted-foreground', - )} - > - {analysis ? ( - <> - {analysis.metrics.volumeUSD24h >= - analysis.requirements.minimumVolumeUSD24h ? ( - - ) : ( - - )} - {formatUSD(analysis.metrics.volumeUSD24h)} - - ) : ( - '-' - )} + +
+
+ + Age{' '} + {analysis ? ` (>${analysis.requirements.minimumAge} Days)` : ''} + + + Daily Volume{' '} + {analysis + ? ` (>${formatUSD(analysis.requirements.minimumVolumeUSD24h)})` + : ''} + + + Market Cap{' '} + {analysis + ? ` (>${formatUSD(analysis.requirements.minimumMarketcapUSD)})` + : ''} + + + Holder Count{' '} + {analysis ? ` (>${analysis.requirements.minimumHolders})` : ''} +
+ +
+
= analysis.requirements.minimumAge + ? 'text-[#139B6D]' + : analysis && + analysis.metrics.age < analysis.requirements.minimumAge + ? 'text-[#B4303C]' + : 'text-muted-foreground', + )} + > + {analysis ? ( + <> + {analysis.metrics.age >= analysis.requirements.minimumAge ? ( + + ) : ( + + )} + {analysis.metrics.age} Days + + ) : ( + '-' + )} +
+
= + analysis.requirements.minimumVolumeUSD24h + ? 'text-[#139B6D]' + : analysis && + analysis.metrics.volumeUSD24h < + analysis.requirements.minimumVolumeUSD24h + ? 'text-[#B4303C]' + : 'text-muted-foreground', + )} + > + {analysis ? ( + <> + {analysis.metrics.volumeUSD24h >= + analysis.requirements.minimumVolumeUSD24h ? ( + + ) : ( + + )} -
= - analysis.requirements.minimumMarketcapUSD - ? 'text-[#139B6D]' - : analysis && - analysis.metrics.marketcapUSD < - analysis.requirements.minimumMarketcapUSD - ? 'text-[#B4303C]' - : 'text-muted-foreground', - )} - > - {analysis ? ( - <> - {analysis.metrics.marketcapUSD >= - analysis.requirements.minimumMarketcapUSD ? ( - - ) : ( - - )} + {formatUSD(analysis.metrics.volumeUSD24h)} + + ) : ( + '-' + )} +
- {formatUSD(analysis.metrics.marketcapUSD)} - - ) : ( - '-' - )} -
+
= + analysis.requirements.minimumMarketcapUSD + ? 'text-[#139B6D]' + : analysis && + analysis.metrics.marketcapUSD < + analysis.requirements.minimumMarketcapUSD + ? 'text-[#B4303C]' + : 'text-muted-foreground', + )} + > + {analysis ? ( + <> + {analysis.metrics.marketcapUSD >= + analysis.requirements.minimumMarketcapUSD ? ( + + ) : ( + + )} + + {formatUSD(analysis.metrics.marketcapUSD)} + + ) : ( + '-' + )} +
-
= analysis.requirements.minimumHolders - ? 'text-[#139B6D]' - : analysis && - analysis.metrics.holders < - analysis.requirements.minimumHolders - ? 'text-[#B4303C]' - : 'text-muted-foreground', - )} - > - {analysis ? ( - <> - {analysis.metrics.holders >= - analysis.requirements.minimumHolders ? ( - - ) : ( - - )} +
= + analysis.requirements.minimumHolders + ? 'text-[#139B6D]' + : analysis && + analysis.metrics.holders < + analysis.requirements.minimumHolders + ? 'text-[#B4303C]' + : 'text-muted-foreground', + )} + > + {analysis ? ( + <> + {analysis.metrics.holders >= + analysis.requirements.minimumHolders ? ( + + ) : ( + + )} - {formatNumber(analysis.metrics.holders)} - - ) : ( - '-' - )} + {formatNumber(analysis.metrics.holders)} + + ) : ( + '-' + )} +
- -
-

Token Status

- {analysis ? ( - isValid ? ( - - ) : ( - - ) + +
+ {isLoading ? ( + + ) : isValid ? ( + + ) : analysis || isError ? ( + ) : ( - <> + )} +

+ Token Status +

-
    + +
      {analysis?.reasoning.map((reason, i) => (
    • {reason}
    • ))} + {isError ?
    • Token not found on coingecko.
    • : null}
    - - +
) } export default function TokenListing() { const methods = useForm({ - // mode: 'all', resolver: zodResolver(ApplyForTokenListTokenSchema), defaultValues: { chainId: ChainId.ETHEREUM, @@ -249,10 +267,11 @@ export default function TokenListing() { }) const isValid = useMemo(() => { + if (isTokenError) return false if (!analysis) return false if (analysis.isExisting) return false return !analysis.isExisting && analysis.isPassingRequirements - }, [analysis]) + }, [analysis, isTokenError]) useEffect(() => { if (isTokenError) @@ -263,10 +282,14 @@ export default function TokenListing() { else methods.clearErrors('address') }, [methods, isTokenError]) - const onSubmit = async (values: ApplyForTokenListTokenSchemaType) => { - try { + const { status, mutateAsync, isPending } = useMutation< + void, + Error, + ApplyForTokenListTokenSchemaType + >({ + mutationFn: async (values) => { const response = await fetch( - 'https://data.sushi.com/token-list/submit-token/v1', + `${SUSHI_DATA_API_HOST}/common/token-list/submit-token/v1`, { method: 'POST', headers: { @@ -275,48 +298,42 @@ export default function TokenListing() { body: JSON.stringify(values), }, ) - console.log({ values, response }) if (!response.ok) { throw new Error(`${response.statusText}`) } - const data = await response.json() - console.log('Response data:', data) methods.reset() - // TODO: success toast? - } catch (error: any) { - console.error('Error:', error.message) - // TODO: error toast? - } - } + }, + }) return ( <> -
-
-

- Community List -

-

- Get your token verified to Sushi's Community List. -

-
-
-
- + +

Community List

+

+ Get your token verified to Sushi's Community List. +

+
+ +
+
- -
+ , + )} + > +
( - - + + { return ( - - + + - - + { return ( - -
- + + - - - - - Give us a tweet including the token address from the - project's official Twitter account. This is not - required, but it increases the chances of getting - approved by verifying ownership of the token. - -
+ + + + + Give us a tweet including the token address from the + project's official Twitter account. This is not + required, but it increases the chances of getting + approved by verifying ownership of the token. +
) }} @@ -407,42 +430,47 @@ export default function TokenListing() { name="logoUrl" render={({ field: { onChange, value, onBlur, name } }) => { return ( - -
- - - - - - Logo URL of your token. -
-
- + + - {logoUrl ? ( - logo - ) : ( - - )} -
+ + + + Logo URL of your token.
) }} /> - {/* {status === 'error' ? ( + + + + + {logoUrl ? ( +
+ logo +
+ ) : ( +
+ +
+ )} +
+ + {status === 'error' ? ( Oops! Something went wrong when trying to execute your request. @@ -459,12 +487,12 @@ export default function TokenListing() { here - ) : null} */} + ) : null} +
-
+

* You can submit your token once it meets the required threshold values. These values may be different depending on the network you diff --git a/apps/web/src/app/tokenlist-request/pending/page.tsx b/apps/web/src/app/tokenlist-request/pending/page.tsx index 4ffb7054e5..d587557824 100644 --- a/apps/web/src/app/tokenlist-request/pending/page.tsx +++ b/apps/web/src/app/tokenlist-request/pending/page.tsx @@ -1,191 +1,298 @@ -import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline' -import { - PendingTokens, - getPendingTokens, -} from '@sushiswap/graph-client/data-api/queries/token-list-submission' +'use client' + +import { ExternalLinkIcon } from '@heroicons/react-v1/solid' +import { PendingTokens } from '@sushiswap/graph-client/data-api/queries/token-list-submission' import { - Button, + Badge, + Card, + CardContent, + CardHeader, + CardTitle, Container, + DataTable, LinkExternal, - LinkInternal, - typographyVariants, + Popover, + PopoverContent, + PopoverTrigger, + SkeletonText, + classNames, } from '@sushiswap/ui' import { NetworkIcon } from '@sushiswap/ui/icons/NetworkIcon' -import { unstable_cache } from 'next/cache' -import React from 'react' -import { Chain, formatUSD, shortenAddress } from 'sushi' - -const XIcon = () => ( - - - - - -) +import { XIcon } from '@sushiswap/ui/icons/XIcon' +import { ColumnDef, SortingState, TableState } from '@tanstack/react-table' +import differenceInDays from 'date-fns/differenceInDays' +import React, { useMemo, useState } from 'react' +import { usePendingTokens } from 'src/lib/hooks/api/usePendingTokenListings' +import { TokenSecurityView } from 'src/lib/wagmi/components/token-security-view' +import { formatNumber, formatUSD, shortenAddress } from 'sushi' +import { Chain } from 'sushi/chain' +import { Token } from 'sushi/currency' +import { getAddress } from 'viem' +import { NavigationItems } from '../navigation-items' -export default async function PendingTokenListingPage() { - const pendingTokens = (await unstable_cache( - async () => await getPendingTokens(), - ['pending-tokens'], - { - revalidate: 60, - }, - )()) as PendingTokens - return ( - <> -

-
- - ← Token Listing - -
-
-

- Pending List -

-

[] = [ + { + id: 'token', + header: 'Token', + accessorFn: (row) => row.token.name, + cell: (props) => ( +

+ + } + > +
+ {props.row.original.token.symbol} +
+
+
+ + {props.row.original.token.name}{' '} + + ({props.row.original.token.symbol}) + + + - Tokens not approved will be deleted after 7 days. You may resubmit - later if there is meaningful progress. Approvals are not guaranteed. -

+ + {shortenAddress(props.row.original.token.address)}{' '} + + +
-
- -
- {/* Header Row */} -
-
- Logo -
- {[ - 'Token', - 'Tweet', - 'Market Cap.', - 'Daily Volume', - 'In Queue', - 'Holders', - ].map((label) => ( -
- - {label} - -
- ))} -
+ ), + meta: { + skeleton: , + }, + }, + { + id: 'tweet', + header: 'Tweet', + accessorFn: (row) => Boolean(row.tweetUrl), + cell: (props) => + props.row.original.tweetUrl ? ( + + + + ) : ( + + ), + meta: { + skeleton: , + }, + }, + { + id: 'marketcapUSD', + header: 'Market Cap', + accessorFn: (row) => row.metrics.marketcapUSD, + cell: (props) => formatUSD(props.row.original.metrics.marketcapUSD), + meta: { + skeleton: , + }, + }, + { + id: 'volumeUSD24h', + header: 'Daily Volume', + accessorFn: (row) => row.metrics.volumeUSD24h, + cell: (props) => formatUSD(props.row.original.metrics.volumeUSD24h), + meta: { + skeleton: , + }, + }, + { + id: 'inQueue', + header: 'In Queue', + accessorFn: (row) => row.createdAt, + cell: (props) => + `${differenceInDays( + new Date(), + new Date(props.row.original.createdAt * 1000), + )} days`, + meta: { + skeleton: , + }, + }, + { + id: 'holders', + header: 'Holders', + accessorFn: (row) => row.metrics.holders, + cell: (props) => formatNumber(props.row.original.metrics.holders), + meta: { + skeleton: , + }, + }, + { + id: 'status', + header: 'Status', + cell: (props) => { + const [trigger, content] = useMemo( + () => [ + 0 + ? 'underline decoration-dotted' + : '', + )} + > + {props.row.original.reasoning.length} detail + {props.row.original.reasoning.length !== 1 ? 's' : ''} + , + + + Details + + +
    + {props.row.original.reasoning.map((reason, i) => ( +
  • {reason}
  • + ))} +
+ +
+
, + ], + [props.row.original], + ) - {/* Data Rows */} - {pendingTokens.length ? ( - pendingTokens.map((pending, index) => ( -
-
-
-
- {pending.token.address} -
- -
-
-
-
-
- {pending.token.name} - - ({pending.token.symbol}) - - - - -
-
- - {pending.tweetUrl ? ( - - - - ) : ( - '' - )} - -
-
- - {formatUSD(pending.metrics.marketcapUSD)} - -
-
- - {formatUSD(pending.metrics.volumeUSD24h)} - + return ( + + e.stopPropagation()} + asChild + className="cursor-pointer" + > + {trigger} + + + {content} + + + ) + }, + meta: { + skeleton: , + }, + }, +] + +export default function PendingTokenListingPage() { + const { data: pendingTokens, isLoading } = usePendingTokens() + + const [sorting, setSorting] = useState([ + { id: 'createdAt', desc: true }, + ]) + + const [data, count] = useMemo( + () => [pendingTokens ?? [], pendingTokens?.length ?? 0], + [pendingTokens], + ) + + const state: Partial = useMemo(() => { + return { + sorting, + pagination: { + pageIndex: 0, + pageSize: data?.length, + }, + } + }, [data?.length, sorting]) + + return ( + <> + +

Pending List

+

+ Tokens not approved will be deleted after 7 days. You may resubmit + later if there is meaningful progress. Approvals are not guaranteed. +

+
+ +
+ + + + + {isLoading ? ( +
+
-
- - {Math.floor( - (new Date().getTime() - - new Date(pending.createdAt * 1000).getTime()) / - (1000 * 60 * 60 * 24), - )}{' '} - Days + ) : ( + + Pending Tokens{' '} + + ({count ?? 0}) -
-
- {pending.metrics.holders} -
-
- )) - ) : ( -
- No pending tokens -
- )} -
+ + )} + + + +
diff --git a/apps/web/src/app/tokenlist-request/providers.tsx b/apps/web/src/app/tokenlist-request/providers.tsx new file mode 100644 index 0000000000..7e1c03ed60 --- /dev/null +++ b/apps/web/src/app/tokenlist-request/providers.tsx @@ -0,0 +1,22 @@ +'use client' + +import '@rainbow-me/rainbowkit/styles.css' + +import { BaseProviders, OnramperProvider } from '@sushiswap/ui' +import { QueryClientProvider } from '../../providers/query-client-provider' +import { WagmiProvider } from '../../providers/wagmi-provider' + +export function Providers({ + children, + cookie, +}: { children: React.ReactNode; cookie: string | null }) { + return ( + + + + {children} + + + + ) +} diff --git a/apps/web/src/lib/hooks/api/index.ts b/apps/web/src/lib/hooks/api/index.ts index ff303982e1..d437fbd512 100644 --- a/apps/web/src/lib/hooks/api/index.ts +++ b/apps/web/src/lib/hooks/api/index.ts @@ -1,3 +1,4 @@ +export * from './useApprovedCommunityTokens' export * from './useCrossChainTrade' export * from './usePoolGraphData' export * from './usePoolsInfinite' diff --git a/apps/web/src/lib/hooks/api/useApprovedCommunityTokens.ts b/apps/web/src/lib/hooks/api/useApprovedCommunityTokens.ts new file mode 100644 index 0000000000..82abcb57c8 --- /dev/null +++ b/apps/web/src/lib/hooks/api/useApprovedCommunityTokens.ts @@ -0,0 +1,15 @@ +'use client' + +import { + ApprovedCommunityTokens, + getApprovedCommunityTokens, +} from '@sushiswap/graph-client/data-api/queries/token-list-submission' +import { useQuery } from '@tanstack/react-query' + +export function useApprovedCommunityTokens(shouldFetch = true) { + return useQuery({ + queryKey: ['approved-tokens'], + queryFn: async () => await getApprovedCommunityTokens(), + enabled: Boolean(shouldFetch), + }) +} diff --git a/apps/web/src/lib/hooks/api/usePendingTokenListings.ts b/apps/web/src/lib/hooks/api/usePendingTokenListings.ts index 6676ea7fe3..6f0e0838b7 100644 --- a/apps/web/src/lib/hooks/api/usePendingTokenListings.ts +++ b/apps/web/src/lib/hooks/api/usePendingTokenListings.ts @@ -1,14 +1,15 @@ 'use client' -import { getPendingTokens, PendingTokens } from '@sushiswap/graph-client/data-api/queries/token-list-submission' +import { + PendingTokens, + getPendingTokens, +} from '@sushiswap/graph-client/data-api/queries/token-list-submission' import { useQuery } from '@tanstack/react-query' -export function usePendingTokens( - shouldFetch = true, -) { +export function usePendingTokens(shouldFetch = true) { return useQuery({ queryKey: ['pending-tokens'], queryFn: async () => await getPendingTokens(), enabled: Boolean(shouldFetch), }) -} \ No newline at end of file +} diff --git a/apps/web/src/lib/hooks/api/useTokenAnalysis.ts b/apps/web/src/lib/hooks/api/useTokenAnalysis.ts index 56ad115dd4..9cf641d1bc 100644 --- a/apps/web/src/lib/hooks/api/useTokenAnalysis.ts +++ b/apps/web/src/lib/hooks/api/useTokenAnalysis.ts @@ -1,7 +1,12 @@ 'use client' +import { + GetTokenAnalysis, + TokenAnalysis, + getTokenAnalysis, +} from '@sushiswap/graph-client/data-api/queries/token-list-submission' import { useQuery } from '@tanstack/react-query' -import { getTokenAnalysis, GetTokenAnalysis, TokenAnalysis } from '@sushiswap/graph-client/data-api/queries/token-list-submission' +import { isAddressFast } from 'sushi/validate' export function useTokenAnalysis( args: Partial, @@ -10,6 +15,11 @@ export function useTokenAnalysis( return useQuery({ queryKey: ['token-analysis', args], queryFn: async () => await getTokenAnalysis(args as GetTokenAnalysis), - enabled: Boolean(shouldFetch && args.chainId && args.address), + enabled: Boolean( + shouldFetch && + args.chainId && + args.address && + isAddressFast(args.address), + ), }) -} \ No newline at end of file +} diff --git a/apps/web/src/lib/wagmi/components/token-security-view.tsx b/apps/web/src/lib/wagmi/components/token-security-view.tsx index e81556b261..4f3e33844c 100644 --- a/apps/web/src/lib/wagmi/components/token-security-view.tsx +++ b/apps/web/src/lib/wagmi/components/token-security-view.tsx @@ -77,8 +77,8 @@ export const TokenSecurityView = ({ }, [tokenSecurityResponse, token]) return ( - - + + { + return ( + + + + ) +} From 1f397a013da5f040412c98d6dfa7d267c5166caa Mon Sep 17 00:00:00 2001 From: 0xMasayoshi <0xMasayoshi@protonmail.com> Date: Wed, 2 Oct 2024 15:51:48 +0800 Subject: [PATCH 12/12] fix: approved token list table --- .../app/tokenlist-request/approved/page.tsx | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/apps/web/src/app/tokenlist-request/approved/page.tsx b/apps/web/src/app/tokenlist-request/approved/page.tsx index acb332168c..bb6c3f424b 100644 --- a/apps/web/src/app/tokenlist-request/approved/page.tsx +++ b/apps/web/src/app/tokenlist-request/approved/page.tsx @@ -22,30 +22,39 @@ import { shortenAddress } from 'sushi/format' import { NavigationItems } from '../navigation-items' const COLUMNS: ColumnDef[] = [ + { + id: 'chain', + header: 'Network', + accessorFn: (row) => row.chainId, + cell: (props) => Chain.from(props.row.original.chainId)?.name, + meta: { skeleton: }, + }, { id: 'logo', header: 'Logo', cell: (props) => ( - - } - > -
- {props.row.original.symbol} -
-
+
+ + } + > +
+ {props.row.original.symbol} +
+
+
), meta: { skeleton: ,