Skip to content

Commit

Permalink
Merge pull request #9408 from hicommonwealth/malik.9371.store-token-info
Browse files Browse the repository at this point in the history
[ERC20 Launchpad] - Store token info
  • Loading branch information
mzparacha authored Oct 9, 2024
2 parents 270c485 + 84c862e commit d99025f
Show file tree
Hide file tree
Showing 23 changed files with 435 additions and 3 deletions.
1 change: 1 addition & 0 deletions libs/adapters/src/trpc/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export enum Tag {
Webhook = 'Webhook',
SuperAdmin = 'SuperAdmin',
DiscordBot = 'DiscordBot',
Token = 'Token',
}

export type Commit<Input extends ZodSchema, Output extends ZodSchema> = (
Expand Down
1 change: 1 addition & 0 deletions libs/model/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 4 additions & 0 deletions libs/model/src/models/associations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions libs/model/src/models/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -88,6 +89,7 @@ export const Factories = {
Vote,
Webhook,
Wallets,
Token,
XpLog,
};

Expand Down
41 changes: 41 additions & 0 deletions libs/model/src/models/token.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Token> & {
// associations
ChainNode?: ChainNodeAttributes;
};

export type TokenInstance = ModelInstance<TokenAttributes> & {
// add mixins as needed
getChainNode: Sequelize.BelongsToGetAssociationMixin<ChainNodeInstance>;
};

export default (
sequelize: Sequelize.Sequelize,
): Sequelize.ModelStatic<TokenInstance> =>
sequelize.define<TokenInstance>(
'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,
},
);
71 changes: 71 additions & 0 deletions libs/model/src/token/CreateToken.command.ts
Original file line number Diff line number Diff line change
@@ -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();
},
};
}
72 changes: 72 additions & 0 deletions libs/model/src/token/GetTokens.query.ts
Original file line number Diff line number Diff line change
@@ -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<typeof schemas.GetTokens> {
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<typeof schemas.Token> & { total?: number }
>(sql, {
replacements,
type: QueryTypes.SELECT,
nest: true,
});

return schemas.buildPaginatedResponse(
tokens,
+(tokens.at(0)?.total ?? 0),
{
limit,
offset,
},
);
},
};
}
2 changes: 2 additions & 0 deletions libs/model/src/token/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './CreateToken.command';
export * from './GetTokens.query';
1 change: 1 addition & 0 deletions libs/schemas/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
18 changes: 18 additions & 0 deletions libs/schemas/src/commands/token.schemas.ts
Original file line number Diff line number Diff line change
@@ -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,
};
1 change: 1 addition & 0 deletions libs/schemas/src/entities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
21 changes: 21 additions & 0 deletions libs/schemas/src/entities/token.schemas.ts
Original file line number Diff line number Diff line change
@@ -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(),
});
1 change: 1 addition & 0 deletions libs/schemas/src/queries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
13 changes: 13 additions & 0 deletions libs/schemas/src/queries/token.schemas.ts
Original file line number Diff line number Diff line change
@@ -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(),
}),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { trpc } from 'utils/trpcClient';

const useCreateTokenMutation = () => {
return trpc.token.createToken.useMutation();
};

export default useCreateTokenMutation;
Original file line number Diff line number Diff line change
@@ -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<typeof GetTokens.input> & {
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;
4 changes: 4 additions & 0 deletions packages/commonwealth/client/scripts/state/api/token/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import useCreateTokenMutation from './createToken';
import useFetchTokensQuery from './fetchTokens';

export { useCreateTokenMutation, useFetchTokensQuery };
Loading

0 comments on commit d99025f

Please sign in to comment.