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/approved/page.tsx b/apps/web/src/app/tokenlist-request/approved/page.tsx new file mode 100644 index 0000000000..bb6c3f424b --- /dev/null +++ b/apps/web/src/app/tokenlist-request/approved/page.tsx @@ -0,0 +1,153 @@ +'use client' + +import { ExternalLinkIcon } from '@heroicons/react-v1/solid' +import { ApprovedCommunityTokens } from '@sushiswap/graph-client/data-api/queries/token-list-submission' +import { + Badge, + Card, + CardHeader, + CardTitle, + Container, + DataTable, + LinkExternal, + SkeletonCircle, + SkeletonText, +} from '@sushiswap/ui' +import { NetworkIcon } from '@sushiswap/ui/icons/NetworkIcon' +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' + +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} +
+
+
+ ), + 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: , + }, + }, +] + +export default function ApprovedTokensPage() { + const { data, isLoading } = useApprovedCommunityTokens() + + const [sorting, setSorting] = useState([ + { id: 'createdAt', desc: true }, + ]) + + const state: Partial = useMemo(() => { + return { + sorting, + pagination: { + pageIndex: 0, + pageSize: data?.length ?? 0, + }, + } + }, [data?.length, sorting]) + + 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 new file mode 100644 index 0000000000..c3d81de0d5 --- /dev/null +++ b/apps/web/src/app/tokenlist-request/header.tsx @@ -0,0 +1,18 @@ +'use client' + +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' + +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 338a92c96b..2b0bd3809b 100644 --- a/apps/web/src/app/tokenlist-request/layout.tsx +++ b/apps/web/src/app/tokenlist-request/layout.tsx @@ -1,12 +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 3e3dce1184..83a4d5b237 100644 --- a/apps/web/src/app/tokenlist-request/page.tsx +++ b/apps/web/src/app/tokenlist-request/page.tsx @@ -1,8 +1,16 @@ '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, Container, Form, FormControl, @@ -11,186 +19,322 @@ import { FormMessage, Label, LinkExternal, + Loader, Message, NetworkSelector, SelectIcon, Separator, TextField, - typographyVariants, + classNames, } 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 { useApplyForTokenList } from 'src/lib/hooks/react-query' +import { useMutation } from '@tanstack/react-query' +import { useEffect, useMemo } from 'react' +import { SubmitHandler, useForm } from 'react-hook-form' +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 { 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 { - ApplyForTokenListListType, ApplyForTokenListTokenSchema, ApplyForTokenListTokenSchemaType, } from './schema' -export default function Partner() { +const Metrics = ({ + analysis, + isValid, + isLoading, + isError, +}: { + 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)} + + ) : ( + '-' + )} +
+ +
= + 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)} + + ) : ( + '-' + )} +
+
+
+
+ +
+ {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, - listType: ApplyForTokenListListType.DEFAULT, - logoFile: '', - tokenAddress: '', + logoUrl: undefined, + address: undefined, }, }) - - const [chainId, tokenAddress, logoFile] = methods.watch([ + const [chainId, address, logoUrl] = methods.watch([ 'chainId', - 'tokenAddress', - 'logoFile', + 'address', + 'logoUrl', ]) - const { data: token, isError: isTokenError } = useTokenWithCache({ - address: tokenAddress as Address, + const { + data: analysis, + isLoading, + isError: isTokenError, + } = useTokenAnalysis({ + address, chainId, - enabled: isAddress(tokenAddress, { strict: false }), }) - const { mutate, isPending, data, status } = useApplyForTokenList() + + const isValid = useMemo(() => { + if (isTokenError) return false + if (!analysis) return false + if (analysis.isExisting) return false + return !analysis.isExisting && analysis.isPassingRequirements + }, [analysis, isTokenError]) useEffect(() => { if (isTokenError) - methods.setError('tokenAddress', { + methods.setError('address', { type: 'custom', message: 'Token not found', }) - else methods.clearErrors('tokenAddress') + else methods.clearErrors('address') }, [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) - } - } - } - }) - }) + const { status, mutateAsync, isPending } = useMutation< + void, + Error, + ApplyForTokenListTokenSchemaType + >({ + mutationFn: async (values) => { + const response = await fetch( + `${SUSHI_DATA_API_HOST}/common/token-list/submit-token/v1`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(values), + }, + ) + if (!response.ok) { + throw new Error(`${response.statusText}`) + } + methods.reset() }, - [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. + <> + +

Community List

+

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

-
- -
- - - -
-
- -
- -
- {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} -
- + )} + /> + { + return ( + + + + + + + The contract address of your token. + + + ) + }} + /> + + { + 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. + + + ) + }} + /> + + { + return ( + + + + + + + Logo URL of your token. + + ) + }} + /> + + + + + {logoUrl ? ( +
+ logo +
+ ) : ( +
+ +
+ )} +
+ + {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} + +
+ +
-
- - + + +
+

+ * 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-listings + 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..d587557824 --- /dev/null +++ b/apps/web/src/app/tokenlist-request/pending/page.tsx @@ -0,0 +1,300 @@ +'use client' + +import { ExternalLinkIcon } from '@heroicons/react-v1/solid' +import { PendingTokens } from '@sushiswap/graph-client/data-api/queries/token-list-submission' +import { + Badge, + Card, + CardContent, + CardHeader, + CardTitle, + Container, + DataTable, + LinkExternal, + Popover, + PopoverContent, + PopoverTrigger, + SkeletonText, + classNames, +} from '@sushiswap/ui' +import { NetworkIcon } from '@sushiswap/ui/icons/NetworkIcon' +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' + +const COLUMNS: ColumnDef[] = [ + { + 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}) + + + + + {shortenAddress(props.row.original.token.address)}{' '} + + + +
+
+ ), + 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], + ) + + 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 ? ( +
+ +
+ ) : ( + + Pending Tokens{' '} + + ({count ?? 0}) + + + )} +
+
+ +
+
+
+ + ) +} 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/app/tokenlist-request/schema.ts b/apps/web/src/app/tokenlist-request/schema.ts index b5d66cb2ec..b76904a60d 100644 --- a/apps/web/src/app/tokenlist-request/schema.ts +++ b/apps/web/src/app/tokenlist-request/schema.ts @@ -2,30 +2,18 @@ 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, - ), + address: ZpdAddress.transform((address) => getAddress(address)), 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(), + logoUrl: z.string().url(), + tweetUrl: z.string().url().startsWith('https://x.com/').optional(), }) export type ApplyForTokenListTokenSchemaType = z.infer< diff --git a/apps/web/src/lib/hooks/api/index.ts b/apps/web/src/lib/hooks/api/index.ts index 5527fc31c2..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' @@ -6,3 +7,5 @@ export * from './useSkaleEuropaFaucet' export * from './useSushiV2UserPositions' export * from './useV2Pool' export * from './useVault' +export * from './useTokenAnalysis' +export * from './usePendingTokenListings' 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 new file mode 100644 index 0000000000..6f0e0838b7 --- /dev/null +++ b/apps/web/src/lib/hooks/api/usePendingTokenListings.ts @@ -0,0 +1,15 @@ +'use client' + +import { + PendingTokens, + getPendingTokens, +} 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), + }) +} diff --git a/apps/web/src/lib/hooks/api/useTokenAnalysis.ts b/apps/web/src/lib/hooks/api/useTokenAnalysis.ts new file mode 100644 index 0000000000..9cf641d1bc --- /dev/null +++ b/apps/web/src/lib/hooks/api/useTokenAnalysis.ts @@ -0,0 +1,25 @@ +'use client' + +import { + GetTokenAnalysis, + TokenAnalysis, + getTokenAnalysis, +} from '@sushiswap/graph-client/data-api/queries/token-list-submission' +import { useQuery } from '@tanstack/react-query' +import { isAddressFast } from 'sushi/validate' + +export function useTokenAnalysis( + args: Partial, + shouldFetch = true, +) { + return useQuery({ + queryKey: ['token-analysis', args], + queryFn: async () => await getTokenAnalysis(args as GetTokenAnalysis), + enabled: Boolean( + shouldFetch && + args.chainId && + args.address && + isAddressFast(args.address), + ), + }) +} 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 ( - - + + = ({ <>
- {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, }, } 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..785d3db2cc --- /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 = `${SUSHI_DATA_API_HOST}/graphql` + + 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 new file mode 100644 index 0000000000..263b7b7f9e --- /dev/null +++ b/packages/graph-client/src/subgraphs/data-api/queries/token-list-submission/index.ts @@ -0,0 +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/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..672e9f451e --- /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 = `${SUSHI_DATA_API_HOST}/graphql` + + 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> diff --git a/packages/graph-client/src/subgraphs/data-api/queries/token-list-submission/token-analysis.ts b/packages/graph-client/src/subgraphs/data-api/queries/token-list-submission/token-analysis.ts new file mode 100644 index 0000000000..a249d1126f --- /dev/null +++ b/packages/graph-client/src/subgraphs/data-api/queries/token-list-submission/token-analysis.ts @@ -0,0 +1,104 @@ +import type { VariablesOf } from 'gql.tada' + +import { request, type RequestOptions } from 'src/lib/request' +import { type ChainId } from 'sushi' +import { SUSHI_DATA_API_HOST } from 'sushi/config/subgraph' +import { graphql } from '../../graphql' +import { SUSHI_REQUEST_HEADERS } from '../../request-headers' +import type { ChainIdVariable } from 'src/lib/types/chainId' + +export const TokenAnalysisQuery = graphql( + ` + query TokenAnalysis($chainId: Int!, $address: Bytes!) { + tokenAnalysis(chainId: $chainId, address: $address) { + token { + id + chainId + address + name + symbol + decimals + } + isExisting + isPending + isPassingRequirements + reasoning + metrics { + age + volumeUSD24h + marketcapUSD + holders + } + requirements { + minimumAge + minimumVolumeUSD24h + minimumMarketcapUSD + minimumHolders + } + 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 type GetTokenAnalysis = VariablesOf & + ChainIdVariable + +/** + * NOTE: This is not intended to be used anywhere else other than the token listing page, do not replace this with goplusapi requests. + * @param variables + * @param options + * @returns + */ +export async function getTokenAnalysis( + variables: GetTokenAnalysis, + options?: RequestOptions, +) { + const url = `${SUSHI_DATA_API_HOST}/graphql` + + const result = await request( + { + url, + document: TokenAnalysisQuery, + variables, + requestHeaders: SUSHI_REQUEST_HEADERS, + }, + options, + ) + + if (result) { + return result.tokenAnalysis + } + + throw new Error('No token analysis found') +} + +export type TokenAnalysis = Awaited> 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! diff --git a/packages/ui/src/components/navigation.tsx b/packages/ui/src/components/navigation.tsx index b512585cea..ed01307413 100644 --- a/packages/ui/src/components/navigation.tsx +++ b/packages/ui/src/components/navigation.tsx @@ -45,7 +45,7 @@ const PARTNER_NAVIGATION_LINKS: NavigationElementDropdown['items'] = [ description: 'Incentivize your token with Sushi rewards.', }, { - title: 'List enquiry', + title: 'Token Listing', href: '/tokenlist-request', description: 'Get your token on our default token list.', }, @@ -225,7 +225,7 @@ const Navigation: React.FC = ({
    - Company + Company
    {COMPANY_NAVIGATION_LINKS.map((component) => (
  • @@ -247,7 +247,7 @@ const Navigation: React.FC = ({
  • - Protocol + Protocol
    {PROTOCOL_NAVIGATION_LINKS.map((component) => (
  • @@ -269,7 +269,7 @@ const Navigation: React.FC = ({
  • - Partnership + Partnership
    {PARTNER_NAVIGATION_LINKS.map((component) => (
  • @@ -291,7 +291,7 @@ const Navigation: React.FC = ({
  • - Support + Support
    {SUPPORT_NAVIGATION_LINKS.map((component) => (
  • diff --git a/packages/ui/src/icons/XIcon.tsx b/packages/ui/src/icons/XIcon.tsx new file mode 100644 index 0000000000..2d64de66b3 --- /dev/null +++ b/packages/ui/src/icons/XIcon.tsx @@ -0,0 +1,20 @@ +import { IconComponent } from '../types' + +export const XIcon: IconComponent = (props) => { + return ( + + + + ) +}