diff --git a/libs/adapters/src/trpc/middleware.ts b/libs/adapters/src/trpc/middleware.ts index 2fd7f0fd4df..72622144444 100644 --- a/libs/adapters/src/trpc/middleware.ts +++ b/libs/adapters/src/trpc/middleware.ts @@ -49,6 +49,7 @@ export enum Tag { Webhook = 'Webhook', SuperAdmin = 'SuperAdmin', DiscordBot = 'DiscordBot', + Token = 'Token', } export type Commit = ( diff --git a/libs/model/src/index.ts b/libs/model/src/index.ts index 1ac07b5d326..aac4bcb42ee 100644 --- a/libs/model/src/index.ts +++ b/libs/model/src/index.ts @@ -12,6 +12,7 @@ export * as Snapshot from './snapshot'; export * as Subscription from './subscription'; export * as SuperAdmin from './super-admin'; export * as Thread from './thread'; +export * as Token from './token'; export * as User from './user'; export * as Wallet from './wallet'; export * as Webhook from './webhook'; diff --git a/libs/model/src/models/associations.ts b/libs/model/src/models/associations.ts index 217d14f39b8..9e2ad3c5956 100644 --- a/libs/model/src/models/associations.ts +++ b/libs/model/src/models/associations.ts @@ -67,6 +67,10 @@ export const buildAssociations = (db: DB) => { .withMany(db.CommunityTags, { onDelete: 'CASCADE', }) + .withOne(db.Token, { + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + }) .withOne(db.DiscordBotConfig, { targetKey: 'discord_config_id', onDelete: 'CASCADE', diff --git a/libs/model/src/models/factories.ts b/libs/model/src/models/factories.ts index b92197182b1..b9148131185 100644 --- a/libs/model/src/models/factories.ts +++ b/libs/model/src/models/factories.ts @@ -38,6 +38,7 @@ import Tags from './tags'; import Thread from './thread'; import ThreadSubscription from './thread_subscriptions'; import ThreadVersionHistory from './thread_version_history'; +import Token from './token'; import Topic from './topic'; import User from './user'; import Vote from './vote'; @@ -88,6 +89,7 @@ export const Factories = { Vote, Webhook, Wallets, + Token, XpLog, }; diff --git a/libs/model/src/models/token.ts b/libs/model/src/models/token.ts new file mode 100644 index 00000000000..28d09ccbee5 --- /dev/null +++ b/libs/model/src/models/token.ts @@ -0,0 +1,41 @@ +import { Token } from '@hicommonwealth/schemas'; +import Sequelize from 'sequelize'; // must use "* as" to avoid scope errors +import { z } from 'zod'; +import type { ChainNodeAttributes, ChainNodeInstance } from './chain_node'; +import type { ModelInstance } from './types'; + +export type TokenAttributes = z.infer & { + // associations + ChainNode?: ChainNodeAttributes; +}; + +export type TokenInstance = ModelInstance & { + // add mixins as needed + getChainNode: Sequelize.BelongsToGetAssociationMixin; +}; + +export default ( + sequelize: Sequelize.Sequelize, +): Sequelize.ModelStatic => + sequelize.define( + 'Token', + { + name: { type: Sequelize.STRING, primaryKey: true }, + icon_url: { type: Sequelize.STRING, allowNull: true }, + description: { type: Sequelize.STRING, allowNull: true }, + symbol: { type: Sequelize.STRING }, + chain_node_id: { type: Sequelize.INTEGER }, + base: { type: Sequelize.STRING, allowNull: false }, + author_address: { type: Sequelize.STRING, allowNull: false }, + community_id: { type: Sequelize.STRING, allowNull: false }, + launchpad_contract_address: { type: Sequelize.STRING, allowNull: false }, + uniswap_pool_address: { type: Sequelize.STRING, allowNull: true }, + }, + { + tableName: 'Tokens', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + underscored: false, + }, + ); diff --git a/libs/model/src/token/CreateToken.command.ts b/libs/model/src/token/CreateToken.command.ts new file mode 100644 index 00000000000..fd6126f5336 --- /dev/null +++ b/libs/model/src/token/CreateToken.command.ts @@ -0,0 +1,71 @@ +import { InvalidInput, type Command } from '@hicommonwealth/core'; +import * as schemas from '@hicommonwealth/schemas'; +import { ChainBase } from '@hicommonwealth/shared'; +import { models } from '../database'; +import { AuthContext, isAuthorized } from '../middleware'; +import { mustExist } from '../middleware/guards'; + +export const CreateTokenErrors = { + TokenNameExists: + 'The name for this token already exists, please choose another name', + InvalidEthereumChainId: 'Ethereum chain ID not provided or unsupported', + InvalidAddress: 'Address is invalid', + InvalidBase: 'Must provide valid chain base', + MissingNodeUrl: 'Missing node url', + InvalidNode: 'RPC url returned invalid response. Check your node url', +}; + +export function CreateToken(): Command< + typeof schemas.CreateToken, + AuthContext +> { + return { + ...schemas.CreateToken, + auth: [isAuthorized({ roles: ['admin'] })], + body: async ({ actor, payload }) => { + const { + base, + chain_node_id, + name, + symbol, + description, + icon_url, + community_id, + launchpad_contract_address, + } = payload; + + const token = await models.Token.findOne({ + where: { name }, + }); + if (token) throw new InvalidInput(CreateTokenErrors.TokenNameExists); + + const baseCommunity = await models.Community.findOne({ + where: { base, id: community_id }, + }); + mustExist('Community Chain Base', baseCommunity); + + const node = await models.ChainNode.findOne({ + where: { id: chain_node_id }, + }); + mustExist(`Chain Node`, node); + + if (base === ChainBase.Ethereum && !node.eth_chain_id) + throw new InvalidInput(CreateTokenErrors.InvalidEthereumChainId); + + const createdToken = await models.Token.create({ + base, + chain_node_id, + name, + symbol, + description, + icon_url, + author_address: actor.address || '', + community_id, + launchpad_contract_address, + // uniswap_pool_address, - TODO: add when uniswap integration is done + }); + + return createdToken!.toJSON(); + }, + }; +} diff --git a/libs/model/src/token/GetTokens.query.ts b/libs/model/src/token/GetTokens.query.ts new file mode 100644 index 00000000000..ac1cf861af9 --- /dev/null +++ b/libs/model/src/token/GetTokens.query.ts @@ -0,0 +1,72 @@ +import { type Query } from '@hicommonwealth/core'; +import * as schemas from '@hicommonwealth/schemas'; +import { QueryTypes } from 'sequelize'; +import { z } from 'zod'; +import { models } from '../database'; + +export function GetTokens(): Query { + return { + ...schemas.GetTokens, + auth: [], + secure: false, + body: async ({ payload }) => { + const { search = '', cursor, limit, order_by, order_direction } = payload; + + // pagination configuration + const direction = order_direction || 'DESC'; + const order_col = order_by || 'name'; + const offset = limit! * (cursor! - 1); + const replacements: { + search?: string; + offset: number; + order_col: string; + direction: string; + limit: number; + } = { + search: search ? `%${search.toLowerCase()}%` : '', + offset, + order_col, + direction, + limit, + }; + + const sql = ` + SELECT + name, + icon_url, + description, + symbol, + chain_node_id, + base, + created_at, + updated_at, + author_address, + community_id, + launchpad_contract_address, + count(*) OVER() AS total + FROM "Tokens" + ${search ? 'WHERE LOWER(name) LIKE :search' : ''} + ORDER BY ${order_col} :direction + LIMIT :limit + OFFSET :offset + `; + + const tokens = await models.sequelize.query< + z.infer & { total?: number } + >(sql, { + replacements, + type: QueryTypes.SELECT, + nest: true, + }); + + return schemas.buildPaginatedResponse( + tokens, + +(tokens.at(0)?.total ?? 0), + { + limit, + offset, + }, + ); + }, + }; +} diff --git a/libs/model/src/token/index.ts b/libs/model/src/token/index.ts new file mode 100644 index 00000000000..c29cc55a208 --- /dev/null +++ b/libs/model/src/token/index.ts @@ -0,0 +1,2 @@ +export * from './CreateToken.command'; +export * from './GetTokens.query'; diff --git a/libs/schemas/src/commands/index.ts b/libs/schemas/src/commands/index.ts index c63bef9fd34..8f5aae8959e 100644 --- a/libs/schemas/src/commands/index.ts +++ b/libs/schemas/src/commands/index.ts @@ -10,6 +10,7 @@ export * from './snapshot.schemas'; export * from './subscription.schemas'; export * from './super-admin.schemas'; export * from './thread.schemas'; +export * from './token.schemas'; export * from './user.schemas'; export * from './wallet.schemas'; export * from './webhook.schemas'; diff --git a/libs/schemas/src/commands/token.schemas.ts b/libs/schemas/src/commands/token.schemas.ts new file mode 100644 index 00000000000..ac2e8482e18 --- /dev/null +++ b/libs/schemas/src/commands/token.schemas.ts @@ -0,0 +1,18 @@ +import { ChainBase } from '@hicommonwealth/shared'; +import { z } from 'zod'; +import { Token } from '../entities'; +import { PG_INT } from '../utils'; + +export const CreateToken = { + input: z.object({ + name: z.string(), + symbol: z.string(), + icon_url: z.string().nullish(), + description: z.string().nullish(), + chain_node_id: PG_INT, + base: z.nativeEnum(ChainBase), + community_id: z.string(), + launchpad_contract_address: z.string(), + }), + output: Token, +}; diff --git a/libs/schemas/src/entities/index.ts b/libs/schemas/src/entities/index.ts index 2180aae9b3a..c69b2707b89 100644 --- a/libs/schemas/src/entities/index.ts +++ b/libs/schemas/src/entities/index.ts @@ -11,6 +11,7 @@ export * from './snapshot.schemas'; export * from './stake.schemas'; export * from './tag.schemas'; export * from './thread.schemas'; +export * from './token.schemas'; export * from './topic.schemas'; export * from './user.schemas'; export * from './wallets.schemas'; diff --git a/libs/schemas/src/entities/token.schemas.ts b/libs/schemas/src/entities/token.schemas.ts new file mode 100644 index 00000000000..398cb52f97f --- /dev/null +++ b/libs/schemas/src/entities/token.schemas.ts @@ -0,0 +1,21 @@ +import { ChainBase } from '@hicommonwealth/shared'; +import { z } from 'zod'; +import { PG_INT } from '../utils'; + +export const Token = z.object({ + // 1. Regular fields are nullish when nullable instead of optional + name: z.string(), + icon_url: z.string().nullish(), + description: z.string().nullish(), + symbol: z.string(), + chain_node_id: PG_INT, + base: z.nativeEnum(ChainBase), + author_address: z.string(), + community_id: z.string(), + launchpad_contract_address: z.string(), + uniswap_pool_address: z.string().optional(), + + // 2. Timestamps are managed by sequelize, thus optional + created_at: z.coerce.date().optional(), + updated_at: z.coerce.date().optional(), +}); diff --git a/libs/schemas/src/queries/index.ts b/libs/schemas/src/queries/index.ts index b2af43c79b5..1b12de97e31 100644 --- a/libs/schemas/src/queries/index.ts +++ b/libs/schemas/src/queries/index.ts @@ -6,4 +6,5 @@ export * from './feed.schemas'; export * from './pagination'; export * from './subscription.schemas'; export * from './thread.schemas'; +export * from './token.schemas'; export * from './user.schemas'; diff --git a/libs/schemas/src/queries/token.schemas.ts b/libs/schemas/src/queries/token.schemas.ts new file mode 100644 index 00000000000..4747d42d8be --- /dev/null +++ b/libs/schemas/src/queries/token.schemas.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; +import { Token } from '../entities'; +import { PaginatedResultSchema, PaginationParamsSchema } from './pagination'; + +export const GetTokens = { + input: PaginationParamsSchema.extend({ + search: z.string().optional(), + order_by: z.enum(['name']).optional(), + }), + output: PaginatedResultSchema.extend({ + results: Token.omit({ uniswap_pool_address: true }).array(), + }), +}; diff --git a/packages/commonwealth/client/scripts/state/api/token/createToken.ts b/packages/commonwealth/client/scripts/state/api/token/createToken.ts new file mode 100644 index 00000000000..592d92b7082 --- /dev/null +++ b/packages/commonwealth/client/scripts/state/api/token/createToken.ts @@ -0,0 +1,7 @@ +import { trpc } from 'utils/trpcClient'; + +const useCreateTokenMutation = () => { + return trpc.token.createToken.useMutation(); +}; + +export default useCreateTokenMutation; diff --git a/packages/commonwealth/client/scripts/state/api/token/fetchTokens.ts b/packages/commonwealth/client/scripts/state/api/token/fetchTokens.ts new file mode 100644 index 00000000000..c82863aad04 --- /dev/null +++ b/packages/commonwealth/client/scripts/state/api/token/fetchTokens.ts @@ -0,0 +1,38 @@ +import { GetTokens } from '@hicommonwealth/schemas'; +import { trpc } from 'utils/trpcClient'; +import { z } from 'zod'; + +const FETCH_TOKENS_STALE_TIME = 60 * 3_000; // 3 mins + +type UseFetchTokensProps = z.infer & { + enabled?: boolean; +}; + +const useFetchTokensQuery = ({ + limit, + order_by, + order_direction, + search, + enabled = true, +}: UseFetchTokensProps) => { + return trpc.token.getTokens.useInfiniteQuery( + { + limit, + order_by, + order_direction, + search, + }, + { + staleTime: FETCH_TOKENS_STALE_TIME, + enabled, + initialCursor: 1, + getNextPageParam: (lastPage) => { + const nextPageNum = lastPage.page + 1; + if (nextPageNum <= lastPage.totalPages) return nextPageNum; + return undefined; + }, + }, + ); +}; + +export default useFetchTokensQuery; diff --git a/packages/commonwealth/client/scripts/state/api/token/index.ts b/packages/commonwealth/client/scripts/state/api/token/index.ts new file mode 100644 index 00000000000..bcd683a4e8a --- /dev/null +++ b/packages/commonwealth/client/scripts/state/api/token/index.ts @@ -0,0 +1,4 @@ +import useCreateTokenMutation from './createToken'; +import useFetchTokensQuery from './fetchTokens'; + +export { useCreateTokenMutation, useFetchTokensQuery }; diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/SignatureStep/SignTokenTransactions/SignTokenTransactions.tsx b/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/SignatureStep/SignTokenTransactions/SignTokenTransactions.tsx index 05af391fb4f..fae2a57323d 100644 --- a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/SignatureStep/SignTokenTransactions/SignTokenTransactions.tsx +++ b/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/SignatureStep/SignTokenTransactions/SignTokenTransactions.tsx @@ -1,6 +1,8 @@ +import { ChainBase, commonProtocol } from '@hicommonwealth/shared'; import React from 'react'; import { useUpdateCommunityMutation } from 'state/api/communities'; import { useLaunchTokenMutation } from 'state/api/launchPad'; +import { useCreateTokenMutation } from 'state/api/token'; import useUserStore from 'state/ui/user'; import { CWDivider } from 'views/components/component_kit/cw_divider'; import { CWText } from 'views/components/component_kit/cw_text'; @@ -28,6 +30,8 @@ const SignTokenTransactions = ({ data: createdToken, } = useLaunchTokenMutation(); + const { mutateAsync: createToken } = useCreateTokenMutation(); + const { mutateAsync: updateCommunity } = useUpdateCommunityMutation({ communityId: createdCommunityId, }); @@ -51,9 +55,21 @@ const SignTokenTransactions = ({ }; await launchToken(payload); - // 2. TODO: Store `tokenInfo` on db - needs api + // 2. store `tokenInfo` on db + await createToken({ + base: ChainBase.Ethereum, + chain_node_id: baseNode.id, + name: payload.name, + symbol: payload.symbol, + icon_url: tokenInfo?.imageURL?.trim() || '', + description: tokenInfo?.description?.trim() || '', + community_id: createdCommunityId, + launchpad_contract_address: + // this will always exist, adding 0 to avoid typescript issues + commonProtocol.factoryContracts[baseNode.ethChainId || 0].launchpad, + }); - // update community to reference the created token + // 3. update community to reference the created token await updateCommunity({ id: createdCommunityId, token_name: payload.name, diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/TokenInformationForm/TokenInformationForm.tsx b/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/TokenInformationForm/TokenInformationForm.tsx index d17afe7921d..0f824d6f966 100644 --- a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/TokenInformationForm/TokenInformationForm.tsx +++ b/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/TokenInformationForm/TokenInformationForm.tsx @@ -8,7 +8,9 @@ import { MixpanelCommunityCreationEvent, MixpanelLoginPayload, } from 'shared/analytics/types'; +import { useFetchTokensQuery } from 'state/api/token'; import useUserStore from 'state/ui/user'; +import { useDebounce } from 'usehooks-ts'; import { CWCoverImageUploader, ImageBehavior, @@ -51,9 +53,28 @@ const TokenInformationForm = ({ const [isAuthModalOpen, setIsAuthModalOpen] = useState(false); const [isProcessingProfileImage, setIsProcessingProfileImage] = useState(false); + const [tokenName, setTokenName] = useState(); const { isAddedToHomeScreen } = useAppStatus(); + const debouncedSearchTerm = useDebounce(tokenName, 500); + + const { data: tokensList } = useFetchTokensQuery({ + cursor: 1, + limit: 50, + search: debouncedSearchTerm, + enabled: !!debouncedSearchTerm, + }); + + const isTokenNameTaken = + tokensList && debouncedSearchTerm + ? !!tokensList.pages[0].results.find( + ({ name }) => + name.toLowerCase().trim() === + debouncedSearchTerm.toLowerCase().trim(), + ) + : false; + const { trackAnalytics } = useBrowserAnalyticsTrack< MixpanelLoginPayload | BaseMixpanelPayload >({ @@ -77,6 +98,8 @@ const TokenInformationForm = ({ const handleSubmit = useCallback( (values: FormSubmitValues) => { + if (isTokenNameTaken) return; + // get address from user if (!selectedAddress) { openAddressSelectionModal(); @@ -86,7 +109,7 @@ const TokenInformationForm = ({ onSubmit(values); // token gets created with signature step, this info is only used to generate community details }, - [openAddressSelectionModal, selectedAddress, onSubmit], + [isTokenNameTaken, openAddressSelectionModal, selectedAddress, onSubmit], ); useEffect(() => { @@ -172,6 +195,8 @@ const TokenInformationForm = ({ label="Token name" placeholder="Name your token" fullWidth + onInput={(e) => setTokenName(e.target.value?.trim())} + customError={isTokenNameTaken ? 'Token name is already taken' : ''} /> { + return queryInterface.sequelize.transaction(async (t) => { + await queryInterface.createTable( + 'Tokens', + { + name: { type: Sequelize.STRING, primaryKey: true }, + icon_url: { type: Sequelize.STRING, allowNull: true }, + description: { type: Sequelize.STRING, allowNull: true }, + symbol: { type: Sequelize.STRING }, + chain_node_id: { type: Sequelize.INTEGER }, + base: { type: Sequelize.STRING, allowNull: false }, + created_at: { type: Sequelize.DATE, allowNull: false }, + updated_at: { type: Sequelize.DATE, allowNull: false }, + author_address: { type: Sequelize.STRING, allowNull: false }, + }, + { + timestamps: true, + transactions: t, + }, + ); + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.dropTable('Tokens', { transaction }); + }); + }, +}; diff --git a/packages/commonwealth/server/migrations/20241008123832-add-token-schema-updates.js b/packages/commonwealth/server/migrations/20241008123832-add-token-schema-updates.js new file mode 100644 index 00000000000..b2246064eeb --- /dev/null +++ b/packages/commonwealth/server/migrations/20241008123832-add-token-schema-updates.js @@ -0,0 +1,52 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (t) => { + await queryInterface.addColumn( + 'Tokens', + 'community_id', + { + type: Sequelize.STRING, + allowNull: false, + references: { model: 'Communities', key: 'id' }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + }, + { transaction: t }, + ); + + await queryInterface.addColumn( + 'Tokens', + 'launchpad_contract_address', + { + type: Sequelize.STRING, + allowNull: false, + }, + { transaction: t }, + ); + + await queryInterface.addColumn( + 'Tokens', + 'uniswap_pool_address', + { + type: Sequelize.STRING, + allowNull: true, + }, + { transaction: t }, + ); + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.sequelize.transaction(async (t) => { + queryInterface.removeColumn('Tokens', 'community_id', { transaction: t }); + queryInterface.removeColumn('Tokens', 'launchpad_contract_address', { + transaction: t, + }); + queryInterface.removeColumn('Tokens', 'uniswap_pool_address', { + transaction: t, + }); + }); + }, +};