diff --git a/libs/model/src/community/CreateTopic.command.ts b/libs/model/src/community/CreateTopic.command.ts index a62d76528cd..ef371105ddd 100644 --- a/libs/model/src/community/CreateTopic.command.ts +++ b/libs/model/src/community/CreateTopic.command.ts @@ -52,7 +52,6 @@ export function CreateTopic(): Command { throw new InvalidState(Errors.StakeNotAllowed); } - // new path: stake or ERC20 if (payload.weighted_voting) { options = { ...options, @@ -60,6 +59,7 @@ export function CreateTopic(): Command { token_address: payload.token_address || undefined, token_symbol: payload.token_symbol || undefined, vote_weight_multiplier: payload.vote_weight_multiplier || undefined, + chain_node_id: payload.chain_node_id || undefined, }; } diff --git a/libs/model/src/community/GetTopics.query.ts b/libs/model/src/community/GetTopics.query.ts index 35b8ef2ddd7..997659d02bb 100644 --- a/libs/model/src/community/GetTopics.query.ts +++ b/libs/model/src/community/GetTopics.query.ts @@ -4,6 +4,7 @@ import * as schemas from '@hicommonwealth/schemas'; import { QueryTypes } from 'sequelize'; import { z } from 'zod'; import { models } from '../database'; +import { buildChainNodeUrl } from '../utils'; const includeContestManagersQuery = ` SELECT td.*, @@ -58,31 +59,35 @@ export function GetTopics(): Query { : 'AND archived_at IS NULL'; const sql = ` - WITH topic_data AS (SELECT id, - name, - community_id, - description, - telegram, - featured_in_sidebar, - featured_in_new_post, - default_offchain_template, - "order", - channel_id, - group_ids, - weighted_voting, - token_symbol, - vote_weight_multiplier, - token_address, - created_at::text AS created_at, - updated_at::text AS updated_at, - deleted_at::text AS deleted_at, - archived_at::text AS archived_at, + WITH topic_data AS (SELECT t.id, + t.name, + t.community_id, + t.description, + t.telegram, + t.featured_in_sidebar, + t.featured_in_new_post, + t.default_offchain_template, + t."order", + t.channel_id, + t.group_ids, + t.weighted_voting, + t.token_symbol, + t.vote_weight_multiplier, + t.token_address, + cn.url as chain_node_url, + cn.eth_chain_id as eth_chain_id, + t.created_at::text AS created_at, + t.updated_at::text AS updated_at, + t.deleted_at::text AS deleted_at, + t.archived_at::text AS archived_at, (SELECT count(*)::int FROM "Threads" WHERE community_id = :community_id AND topic_id = t.id AND deleted_at IS NULL) AS total_threads FROM "Topics" t + LEFT JOIN "ChainNodes" cn + ON t.chain_node_id = cn.id WHERE t.community_id = :community_id AND t.deleted_at IS NULL ${archivedTopicsQuery}) ${contest_managers} @@ -102,6 +107,9 @@ export function GetTopics(): Query { c.voting_power = BigNumber.from(c.voting_power).toString(); }); }); + if (r.chain_node_url) { + r.chain_node_url = buildChainNodeUrl(r.chain_node_url, 'public'); + } }); return results; diff --git a/libs/model/src/models/associations.ts b/libs/model/src/models/associations.ts index ad561dd60b2..aa2b9a458a7 100644 --- a/libs/model/src/models/associations.ts +++ b/libs/model/src/models/associations.ts @@ -69,7 +69,8 @@ export const buildAssociations = (db: DB) => { db.ChainNode.withMany(db.Community) .withMany(db.EvmEventSource) - .withOne(db.LastProcessedEvmBlock); + .withOne(db.LastProcessedEvmBlock) + .withMany(db.Topic); db.ContractAbi.withMany(db.EvmEventSource, { foreignKey: 'abi_id' }); diff --git a/libs/model/src/models/topic.ts b/libs/model/src/models/topic.ts index 9c279ec3f74..64c79c1f443 100644 --- a/libs/model/src/models/topic.ts +++ b/libs/model/src/models/topic.ts @@ -1,6 +1,7 @@ import { Topic } from '@hicommonwealth/schemas'; import Sequelize from 'sequelize'; import { z } from 'zod'; +import { ChainNodeAttributes } from './chain_node'; import type { CommunityAttributes } from './community'; import type { ThreadAttributes } from './thread'; import type { ModelInstance } from './types'; @@ -9,6 +10,7 @@ export type TopicAttributes = z.infer & { // associations community?: CommunityAttributes; threads?: ThreadAttributes[]; + ChainNode?: ChainNodeAttributes; }; export type TopicInstance = ModelInstance; @@ -49,6 +51,7 @@ export default ( }, telegram: { type: Sequelize.STRING, allowNull: true }, weighted_voting: { type: Sequelize.STRING, allowNull: true }, + chain_node_id: { type: Sequelize.INTEGER, allowNull: true }, token_address: { type: Sequelize.STRING, allowNull: true }, token_symbol: { type: Sequelize.STRING, allowNull: true }, vote_weight_multiplier: { type: Sequelize.FLOAT, allowNull: true }, diff --git a/libs/model/src/services/stakeHelper.ts b/libs/model/src/services/stakeHelper.ts index 6e2dceb93b3..8df6d798205 100644 --- a/libs/model/src/services/stakeHelper.ts +++ b/libs/model/src/services/stakeHelper.ts @@ -1,9 +1,9 @@ import { InvalidState } from '@hicommonwealth/core'; import { commonProtocol } from '@hicommonwealth/evm-protocols'; import { TopicWeightedVoting } from '@hicommonwealth/schemas'; -import { BalanceSourceType } from '@hicommonwealth/shared'; +import { BalanceSourceType, ZERO_ADDRESS } from '@hicommonwealth/shared'; import { BigNumber } from 'ethers'; -import { tokenBalanceCache } from '.'; +import { GetBalancesOptions, tokenBalanceCache } from '.'; import { config } from '../config'; import { models } from '../database'; import { mustExist } from '../middleware/guards'; @@ -40,6 +40,10 @@ export async function getVotingWeight( }, ], }, + { + model: models.ChainNode.scope('withPrivateData'), + required: false, + }, ], }); mustExist('Topic', topic); @@ -47,10 +51,10 @@ export async function getVotingWeight( const { community } = topic; mustExist('Community', community); - const chain_node = community.ChainNode; + const namespaceChainNode = community.ChainNode; if (topic.weighted_voting === TopicWeightedVoting.Stake) { - mustExist('Chain Node Eth Chain Id', chain_node?.eth_chain_id); + mustExist('Chain Node Eth Chain Id', namespaceChainNode?.eth_chain_id); mustExist('Community Namespace Address', community.namespace_address); const stake = topic.community?.CommunityStakes?.at(0); @@ -59,7 +63,7 @@ export async function getVotingWeight( const stakeBalances = await contractHelpers.getNamespaceBalance( community.namespace_address, stake.stake_id, - chain_node.eth_chain_id, + namespaceChainNode.eth_chain_id, [address], ); const stakeBalance = stakeBalances[address]; @@ -68,23 +72,37 @@ export async function getVotingWeight( return commonProtocol.calculateVoteWeight(stakeBalance, stake.vote_weight); } else if (topic.weighted_voting === TopicWeightedVoting.ERC20) { - mustExist('Chain Node Eth Chain Id', chain_node?.eth_chain_id); - const chainNodeUrl = chain_node!.private_url! || chain_node!.url!; + const { eth_chain_id, private_url, url } = topic.ChainNode!; + mustExist('Chain Node Eth Chain Id', eth_chain_id); + const chainNodeUrl = private_url! || url!; mustExist('Chain Node URL', chainNodeUrl); + mustExist('Topic Token Address', topic.token_address); - const balances = await tokenBalanceCache.getBalances({ - balanceSourceType: BalanceSourceType.ERC20, - addresses: [address], - sourceOptions: { - evmChainId: chain_node.eth_chain_id, - contractAddress: topic.token_address!, - }, - cacheRefresh: true, - }); + const balanceOptions: GetBalancesOptions = + topic.token_address == ZERO_ADDRESS + ? { + balanceSourceType: BalanceSourceType.ETHNative, + addresses: [address], + sourceOptions: { + evmChainId: eth_chain_id, + }, + } + : { + balanceSourceType: BalanceSourceType.ERC20, + addresses: [address], + sourceOptions: { + evmChainId: eth_chain_id, + contractAddress: topic.token_address, + }, + }; + + balanceOptions.cacheRefresh = true; + + const balances = await tokenBalanceCache.getBalances(balanceOptions); const tokenBalance = balances[address]; - if (BigNumber.from(tokenBalance).lte(0)) + if (BigNumber.from(tokenBalance || 0).lte(0)) throw new InvalidState('Insufficient token balance'); const result = commonProtocol.calculateVoteWeight( diff --git a/libs/schemas/src/commands/community.schemas.ts b/libs/schemas/src/commands/community.schemas.ts index e77ac11ab64..90642eccb41 100644 --- a/libs/schemas/src/commands/community.schemas.ts +++ b/libs/schemas/src/commands/community.schemas.ts @@ -179,6 +179,7 @@ export const CreateTopic = { token_address: true, token_symbol: true, vote_weight_multiplier: true, + chain_node_id: true, }), ), output: z.object({ diff --git a/libs/schemas/src/entities/topic.schemas.ts b/libs/schemas/src/entities/topic.schemas.ts index d76eae56fd7..a383d3d55ec 100644 --- a/libs/schemas/src/entities/topic.schemas.ts +++ b/libs/schemas/src/entities/topic.schemas.ts @@ -32,6 +32,11 @@ export const Topic = z.object({ group_ids: z.array(PG_INT).default([]), default_offchain_template_backup: z.string().nullish(), weighted_voting: z.nativeEnum(TopicWeightedVoting).nullish(), + chain_node_id: z + .number() + .int() + .nullish() + .describe('token chain node ID, used for ERC20 topics'), token_address: z .string() .nullish() diff --git a/libs/schemas/src/queries/community.schemas.ts b/libs/schemas/src/queries/community.schemas.ts index e894a12fae0..bc28557050e 100644 --- a/libs/schemas/src/queries/community.schemas.ts +++ b/libs/schemas/src/queries/community.schemas.ts @@ -189,6 +189,9 @@ export const TopicView = Topic.extend({ contest_topics: z.undefined(), total_threads: z.number().default(0), active_contest_managers: z.array(ConstestManagerView).optional(), + chain_node_id: z.number().nullish().optional(), + chain_node_url: z.string().nullish().optional(), + eth_chain_id: z.number().nullish().optional(), }); export const GetTopics = { diff --git a/packages/commonwealth/client/scripts/views/components/TokenBanner/TokenBanner.tsx b/packages/commonwealth/client/scripts/views/components/TokenBanner/TokenBanner.tsx index 86db9c57e85..1ca65b12637 100644 --- a/packages/commonwealth/client/scripts/views/components/TokenBanner/TokenBanner.tsx +++ b/packages/commonwealth/client/scripts/views/components/TokenBanner/TokenBanner.tsx @@ -47,10 +47,10 @@ const TokenBanner = ({ ) : (
- {(name || 'Token').charAt(0).toUpperCase()} + {(name || 'ETH').charAt(0).toUpperCase()}
)} - {name} + {name || 'ETH'} {ticker} diff --git a/packages/commonwealth/client/scripts/views/components/TokenFinder/TokenFinder.tsx b/packages/commonwealth/client/scripts/views/components/TokenFinder/TokenFinder.tsx index 40dcb6b7ba4..4c9bea30a8d 100644 --- a/packages/commonwealth/client/scripts/views/components/TokenFinder/TokenFinder.tsx +++ b/packages/commonwealth/client/scripts/views/components/TokenFinder/TokenFinder.tsx @@ -34,7 +34,6 @@ const TokenFinder = ({ onInput={(e) => setTokenValue(e.target.value.trim())} customError={tokenError} /> - {debouncedTokenValue && !tokenError && ( { + if (tokenValue === ZERO_ADDRESS) { + return; + } if (isOneOff && !tokenValue) { return 'You must enter a token address'; } @@ -34,7 +46,8 @@ const useTokenFinder = ({ tokenValue, setTokenValue, debouncedTokenValue, - tokenMetadata, + tokenMetadata: + tokenValue === ZERO_ADDRESS ? nativeTokenMetadata : tokenMetadata, tokenMetadataLoading, getTokenError, }; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Topics/WVERC20Details/WVERC20Details.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Topics/WVERC20Details/WVERC20Details.tsx index 2115e354156..c56802a236f 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Topics/WVERC20Details/WVERC20Details.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Topics/WVERC20Details/WVERC20Details.tsx @@ -11,6 +11,8 @@ import { CWTextInput } from 'views/components/component_kit/new_designs/CWTextIn import { CreateTopicStep } from '../utils'; import { TopicWeightedVoting } from '@hicommonwealth/schemas'; +import { ZERO_ADDRESS } from '@hicommonwealth/shared'; +import { CWCheckbox } from 'client/scripts/views/components/component_kit/cw_checkbox'; import { notifyError } from 'controllers/app/notifications'; import TokenFinder, { useTokenFinder } from 'views/components/TokenFinder'; import { HandleCreateTopicProps } from 'views/pages/CommunityManagement/Topics/Topics'; @@ -38,7 +40,10 @@ const WVERC20Details = ({ onStepChange, onCreateTopic }: WVConsentProps) => { tokenMetadataLoading, tokenValue, } = useTokenFinder({ - nodeEthChainId: app.chain.meta.ChainNode?.eth_chain_id || 0, + nodeEthChainId: + Number(selectedChain?.value) || + app.chain.meta.ChainNode?.eth_chain_id || + 0, }); const editMode = false; @@ -69,7 +74,7 @@ const WVERC20Details = ({ onStepChange, onCreateTopic }: WVConsentProps) => { - Connect ERC20 token + Connect ERC20/ETH Your community chain @@ -77,7 +82,6 @@ const WVERC20Details = ({ onStepChange, onCreateTopic }: WVConsentProps) => { selection is only available when the community is created { setTokenValue={setTokenValue} tokenValue={tokenValue} containerClassName="token-input" - disabled={editMode} + disabled={editMode || tokenValue == ZERO_ADDRESS} fullWidth tokenError={getTokenError()} /> + { + if (tokenValue == ZERO_ADDRESS) { + setTokenValue(''); + } else { + setTokenValue(ZERO_ADDRESS); + } + }} + /> Vote weight multiplier diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Topics/WVMethodSelection/WVMethodSelection.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Topics/WVMethodSelection/WVMethodSelection.tsx index ce9723cf6e2..d941674dda5 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Topics/WVMethodSelection/WVMethodSelection.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Topics/WVMethodSelection/WVMethodSelection.tsx @@ -64,8 +64,8 @@ const WVMethodSelection = ({ { const { data: erc20Balance } = useGetERC20BalanceQuery({ tokenAddress: topicObj?.token_address || '', userAddress: user.activeAccount?.address || '', - nodeRpc: app?.chain.meta?.ChainNode?.url || '', + nodeRpc: topicObj?.chain_node_url || app?.chain.meta?.ChainNode?.url || '', + enabled: topicObj?.token_address !== ZERO_ADDRESS, + }); + + const { data: userEthBalance } = useGetUserEthBalanceQuery({ + chainRpc: topicObj?.chain_node_url || '', + walletAddress: user.activeAccount?.address || '', + ethChainId: topicObj?.eth_chain_id || 0, + apiEnabled: topicObj?.token_address === ZERO_ADDRESS, }); const { dateCursor } = useDateCursor({ @@ -123,7 +136,8 @@ const DiscussionsPage = ({ topicName }: DiscussionsPageProps) => { const { data: tokenMetadata } = useTokenMetadataQuery({ tokenId: topicObj?.token_address || '', - nodeEthChainId: app?.chain.meta?.ChainNode?.eth_chain_id || 0, + nodeEthChainId: + topicObj?.eth_chain_id || app?.chain.meta?.ChainNode?.eth_chain_id || 0, }); const { fetchNextPage, data, isInitialLoading, hasNextPage, threadCount } = @@ -203,11 +217,14 @@ const DiscussionsPage = ({ topicName }: DiscussionsPageProps) => { return isContestInTopic && isActive; }); + const voteBalance = + topicObj?.token_address === ZERO_ADDRESS ? userEthBalance : erc20Balance; + const voteWeight = - isTopicWeighted && erc20Balance + isTopicWeighted && voteBalance ? String( ( - (topicObj?.vote_weight_multiplier || 1) * Number(erc20Balance) + (topicObj?.vote_weight_multiplier || 1) * Number(voteBalance) ).toFixed(0), ) : ''; diff --git a/packages/commonwealth/server/migrations/20241126184908-add-topic-token-chain-node.js b/packages/commonwealth/server/migrations/20241126184908-add-topic-token-chain-node.js new file mode 100644 index 00000000000..74c1ad6c21d --- /dev/null +++ b/packages/commonwealth/server/migrations/20241126184908-add-topic-token-chain-node.js @@ -0,0 +1,21 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn('Topics', 'chain_node_id', { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: 'ChainNodes', + key: 'id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.removeColumn('Topics', 'chain_node_id'); + }, +};