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,
+ });
+ });
+ },
+};