Repository: hicommonwealth/commonwealth
Pull request #11721: Implement token tier ChainVerified policy
Author: login: rbentcw
Created: 2025-04-02T18:13:28Z | Merged: 2025-04-18T18:30:26Z

<!--- Provide a general summary of your changes in the Title above -->

## Link to Issue
Closes: #11633 

## Description of Changes
- Adds `UpgradeTierPolicy` which upgrades user to tier 4 under various conditions

## Test Plan
- Covered via lifecycle test

## Deployment Plan
N/A

## Other Considerations
N/A


Diff:
diff --git a/libs/evm-protocols/src/abis/erc1155Abi.ts b/libs/evm-protocols/src/abis/erc1155Abi.ts
new file mode 100644
index 00000000000..9c885ec93a3
--- /dev/null
+++ b/libs/evm-protocols/src/abis/erc1155Abi.ts
@@ -0,0 +1,314 @@
+export const erc1155Abi = [
+  {
+    anonymous: false,
+    inputs: [
+      {
+        indexed: true,
+        internalType: 'address',
+        name: 'account',
+        type: 'address',
+      },
+      {
+        indexed: true,
+        internalType: 'address',
+        name: 'operator',
+        type: 'address',
+      },
+      {
+        indexed: false,
+        internalType: 'bool',
+        name: 'approved',
+        type: 'bool',
+      },
+    ],
+    name: 'ApprovalForAll',
+    type: 'event',
+  },
+  {
+    anonymous: false,
+    inputs: [
+      {
+        indexed: true,
+        internalType: 'address',
+        name: 'operator',
+        type: 'address',
+      },
+      {
+        indexed: true,
+        internalType: 'address',
+        name: 'from',
+        type: 'address',
+      },
+      {
+        indexed: true,
+        internalType: 'address',
+        name: 'to',
+        type: 'address',
+      },
+      {
+        indexed: false,
+        internalType: 'uint256[]',
+        name: 'ids',
+        type: 'uint256[]',
+      },
+      {
+        indexed: false,
+        internalType: 'uint256[]',
+        name: 'values',
+        type: 'uint256[]',
+      },
+    ],
+    name: 'TransferBatch',
+    type: 'event',
+  },
+  {
+    anonymous: false,
+    inputs: [
+      {
+        indexed: true,
+        internalType: 'address',
+        name: 'operator',
+        type: 'address',
+      },
+      {
+        indexed: true,
+        internalType: 'address',
+        name: 'from',
+        type: 'address',
+      },
+      {
+        indexed: true,
+        internalType: 'address',
+        name: 'to',
+        type: 'address',
+      },
+      {
+        indexed: false,
+        internalType: 'uint256',
+        name: 'id',
+        type: 'uint256',
+      },
+      {
+        indexed: false,
+        internalType: 'uint256',
+        name: 'value',
+        type: 'uint256',
+      },
+    ],
+    name: 'TransferSingle',
+    type: 'event',
+  },
+  {
+    anonymous: false,
+    inputs: [
+      {
+        indexed: false,
+        internalType: 'string',
+        name: 'value',
+        type: 'string',
+      },
+      {
+        indexed: true,
+        internalType: 'uint256',
+        name: 'id',
+        type: 'uint256',
+      },
+    ],
+    name: 'URI',
+    type: 'event',
+  },
+  {
+    inputs: [
+      {
+        internalType: 'address',
+        name: 'account',
+        type: 'address',
+      },
+      {
+        internalType: 'uint256',
+        name: 'id',
+        type: 'uint256',
+      },
+    ],
+    name: 'balanceOf',
+    outputs: [
+      {
+        internalType: 'uint256',
+        name: '',
+        type: 'uint256',
+      },
+    ],
+    stateMutability: 'view',
+    type: 'function',
+  },
+  {
+    inputs: [
+      {
+        internalType: 'address[]',
+        name: 'accounts',
+        type: 'address[]',
+      },
+      {
+        internalType: 'uint256[]',
+        name: 'ids',
+        type: 'uint256[]',
+      },
+    ],
+    name: 'balanceOfBatch',
+    outputs: [
+      {
+        internalType: 'uint256[]',
+        name: '',
+        type: 'uint256[]',
+      },
+    ],
+    stateMutability: 'view',
+    type: 'function',
+  },
+  {
+    inputs: [
+      {
+        internalType: 'address',
+        name: 'account',
+        type: 'address',
+      },
+      {
+        internalType: 'address',
+        name: 'operator',
+        type: 'address',
+      },
+    ],
+    name: 'isApprovedForAll',
+    outputs: [
+      {
+        internalType: 'bool',
+        name: '',
+        type: 'bool',
+      },
+    ],
+    stateMutability: 'view',
+    type: 'function',
+  },
+  {
+    inputs: [
+      {
+        internalType: 'address',
+        name: 'from',
+        type: 'address',
+      },
+      {
+        internalType: 'address',
+        name: 'to',
+        type: 'address',
+      },
+      {
+        internalType: 'uint256[]',
+        name: 'ids',
+        type: 'uint256[]',
+      },
+      {
+        internalType: 'uint256[]',
+        name: 'amounts',
+        type: 'uint256[]',
+      },
+      {
+        internalType: 'bytes',
+        name: 'data',
+        type: 'bytes',
+      },
+    ],
+    name: 'safeBatchTransferFrom',
+    outputs: [],
+    stateMutability: 'nonpayable',
+    type: 'function',
+  },
+  {
+    inputs: [
+      {
+        internalType: 'address',
+        name: 'from',
+        type: 'address',
+      },
+      {
+        internalType: 'address',
+        name: 'to',
+        type: 'address',
+      },
+      {
+        internalType: 'uint256',
+        name: 'id',
+        type: 'uint256',
+      },
+      {
+        internalType: 'uint256',
+        name: 'amount',
+        type: 'uint256',
+      },
+      {
+        internalType: 'bytes',
+        name: 'data',
+        type: 'bytes',
+      },
+    ],
+    name: 'safeTransferFrom',
+    outputs: [],
+    stateMutability: 'nonpayable',
+    type: 'function',
+  },
+  {
+    inputs: [
+      {
+        internalType: 'address',
+        name: 'operator',
+        type: 'address',
+      },
+      {
+        internalType: 'bool',
+        name: 'approved',
+        type: 'bool',
+      },
+    ],
+    name: 'setApprovalForAll',
+    outputs: [],
+    stateMutability: 'nonpayable',
+    type: 'function',
+  },
+  {
+    inputs: [
+      {
+        internalType: 'bytes4',
+        name: 'interfaceId',
+        type: 'bytes4',
+      },
+    ],
+    name: 'supportsInterface',
+    outputs: [
+      {
+        internalType: 'bool',
+        name: '',
+        type: 'bool',
+      },
+    ],
+    stateMutability: 'view',
+    type: 'function',
+  },
+  {
+    inputs: [
+      {
+        internalType: 'uint256',
+        name: 'id',
+        type: 'uint256',
+      },
+    ],
+    name: 'uri',
+    outputs: [
+      {
+        internalType: 'string',
+        name: '',
+        type: 'string',
+      },
+    ],
+    stateMutability: 'view',
+    type: 'function',
+  },
+] as const;
diff --git a/libs/evm-protocols/src/abis/index.ts b/libs/evm-protocols/src/abis/index.ts
index 49b371ae8c3..9459d4a6855 100644
--- a/libs/evm-protocols/src/abis/index.ts
+++ b/libs/evm-protocols/src/abis/index.ts
@@ -1,3 +1,4 @@
 export * from './communityNominationsAbi';
+export * from './erc1155Abi';
 export * from './erc20Abi';
 export * from './veBridgeAbi';
diff --git a/libs/model/src/aggregates/bot/CreateBotNamespace.command.ts b/libs/model/src/aggregates/bot/CreateBotNamespace.command.ts
index 1f0365ee333..64f08e054ea 100644
--- a/libs/model/src/aggregates/bot/CreateBotNamespace.command.ts
+++ b/libs/model/src/aggregates/bot/CreateBotNamespace.command.ts
@@ -80,6 +80,7 @@ export function CreateBotNamespace(): Command<
         directory_page_enabled: false,
         snapshot_spaces: [],
         stages_enabled: true,
+        namespace_verified: false,
       });
       return {
         community,
diff --git a/libs/model/src/aggregates/community/CreateCommunity.command.ts b/libs/model/src/aggregates/community/CreateCommunity.command.ts
index a6794d431cd..8f6d81c1b7a 100644
--- a/libs/model/src/aggregates/community/CreateCommunity.command.ts
+++ b/libs/model/src/aggregates/community/CreateCommunity.command.ts
@@ -154,6 +154,7 @@ export function CreateCommunity(): Command<typeof schemas.CreateCommunity> {
             stages_enabled: true,
             allow_tokenized_threads,
             thread_purchase_token,
+            namespace_verified: false,
           },
           { transaction },
         );
diff --git a/libs/model/src/aggregates/community/DeleteGroup.command.ts b/libs/model/src/aggregates/community/DeleteGroup.command.ts
index 7eded20f1c3..232e3dc6873 100644
--- a/libs/model/src/aggregates/community/DeleteGroup.command.ts
+++ b/libs/model/src/aggregates/community/DeleteGroup.command.ts
@@ -13,7 +13,7 @@ export function DeleteGroup(): Command<typeof schemas.DeleteGroup> {
   return {
     ...schemas.DeleteGroup,
     auth: [authRoles('admin')],
-    body: async ({ actor, payload }) => {
+    body: async ({ payload }) => {
       const { community_id, group_id } = payload;
 
       const group = await models.Group.findOne({
@@ -21,7 +21,8 @@ export function DeleteGroup(): Command<typeof schemas.DeleteGroup> {
       });
       mustExist('Group', group);
 
-      if (group.is_system_managed && !actor.user.isAdmin)
+      // no one can delete system-managed groups
+      if (group.is_system_managed)
         throw new InvalidInput(DeleteGroupErrors.SystemManaged);
 
       await models.sequelize.transaction(async (transaction) => {
diff --git a/libs/model/src/aggregates/community/LinkNamespace.command.ts b/libs/model/src/aggregates/community/LinkNamespace.command.ts
index 11b0ab78be7..f2f5d612606 100644
--- a/libs/model/src/aggregates/community/LinkNamespace.command.ts
+++ b/libs/model/src/aggregates/community/LinkNamespace.command.ts
@@ -1,7 +1,7 @@
 import { type Command } from '@hicommonwealth/core';
 import * as schemas from '@hicommonwealth/schemas';
 import { BalanceSourceType } from '@hicommonwealth/shared';
-import { Transaction } from 'sequelize';
+import { Op, Transaction } from 'sequelize';
 import { z } from 'zod';
 import { models } from '../../database';
 import { mustExist } from '../../middleware/guards';
@@ -109,14 +109,28 @@ export function LinkNamespace(): Command<typeof schemas.LinkNamespace> {
         const NAMESPACE_ADMINS_GROUP_NAME = 'Namespace Admins';
         const COMMUNITY_NOMINATED_GROUP_NAME = 'Community Nominated';
 
-        if (!log_removed) {
-          // don't create group if chain event represents a log removal
-          // TODO: should we remove group when log is removed?
-          // TODO: should we add any default users to this group?
+        if (log_removed) {
+          await models.Group.destroy({
+            where: {
+              community_id: community.id,
+              metadata: {
+                name: {
+                  [Op.in]: [
+                    NAMESPACE_ADMINS_GROUP_NAME,
+                    COMMUNITY_NOMINATED_GROUP_NAME,
+                  ],
+                },
+              },
+              is_system_managed: true,
+            },
+            transaction,
+          });
+        } else {
           await models.Group.findOrCreate({
             where: {
               community_id: community.id,
               metadata: { name: NAMESPACE_ADMINS_GROUP_NAME },
+              is_system_managed: true,
             },
             defaults: {
               community_id: community.id,
@@ -148,6 +162,7 @@ export function LinkNamespace(): Command<typeof schemas.LinkNamespace> {
             where: {
               community_id: community.id,
               metadata: { name: COMMUNITY_NOMINATED_GROUP_NAME },
+              is_system_managed: true,
             },
             defaults: {
               community_id: community.id,
diff --git a/libs/model/src/aggregates/user/UpgradeTier.policy.ts b/libs/model/src/aggregates/user/UpgradeTier.policy.ts
new file mode 100644
index 00000000000..b1d17136c69
--- /dev/null
+++ b/libs/model/src/aggregates/user/UpgradeTier.policy.ts
@@ -0,0 +1,273 @@
+import { logger, Policy } from '@hicommonwealth/core';
+import { commonProtocol } from '@hicommonwealth/evm-protocols';
+import { events } from '@hicommonwealth/schemas';
+import { UserTierMap } from '@hicommonwealth/shared';
+import { Op, QueryTypes, Transaction } from 'sequelize';
+import { models } from '../../database';
+import {
+  USDC_BASE_MAINNET_ADDRESS,
+  USDC_BASE_SEPOLIA_ADDRESS,
+} from '../../services/openai/parseBotCommand';
+import { findActiveContestManager } from '../../utils/findActiveContestManager';
+import { getChainNodeUrl } from '../../utils/utils';
+
+export const UPGRADE_MIN_TRADE_AMOUNT = 250_000_000_000_000_000n; // 2.5 * 10^17
+export const UPGRADE_MIN_USDC_BALANCE = 50_000_000_000_000_000_000n; // 50 * 10^18
+export const UPGRADE_MIN_LAUNCHPAD_BALANCE = 1_000_000_000_000_000_000n; // 1 * 10^18
+
+const log = logger(import.meta);
+
+const inputs = {
+  LaunchpadTokenTraded: events.LaunchpadTokenTraded,
+  ContestContentAdded: events.ContestContentAdded,
+  ContestContentUpvoted: events.ContestContentUpvoted,
+};
+
+export function UpgradeTierPolicy(): Policy<typeof inputs> {
+  return {
+    inputs,
+    body: {
+      // TODO: update to nomination event
+
+      // NamespaceTransferSingle: async ({ payload }) => {
+      //   const {
+      //     parsedArgs: { from, to: userAddress, id: tokenId },
+      //     rawLog: { address: namespaceAddress },
+      //   } = payload;
+      //   if (tokenId !== BigInt(NAMESPACE_COMMUNITY_NOMINATION_TOKEN_ID)) return; // must be community nomination token
+
+      //   if (from !== ZERO_ADDRESS) return; // must be minted
+
+      //   const community = await models.Community.findOne({
+      //     where: { namespace_address: namespaceAddress },
+      //   });
+      //   if (!community) {
+      //     log.warn(
+      //       `Community not found for namespace address ${namespaceAddress}`,
+      //     );
+      //     return;
+      //   }
+
+      //   const nominatedAddress = await models.Address.findOne({
+      //     where: { address: userAddress },
+      //     include: [
+      //       {
+      //         model: models.User,
+      //         required: true,
+      //       },
+      //     ],
+      //   });
+      //   if (!nominatedAddress?.User) {
+      //     log.warn(`User not found for address ${userAddress}`);
+      //     return;
+      //   }
+      //   if (nominatedAddress.User.tier >= UserTierMap.ChainVerified) return;
+
+      //   // if user has sufficient balance of community nomination token, upgrade to ChainVerified tier
+      //   const balances = await tokenBalanceCache.getBalances({
+      //     addresses: [nominatedAddress.address],
+      //     balanceSourceType: BalanceSourceType.ERC1155,
+      //     sourceOptions: {
+      //       evmChainId: payload.eventSource.ethChainId,
+      //       contractAddress: community.namespace_address!,
+      //       tokenId: NAMESPACE_COMMUNITY_NOMINATION_TOKEN_ID,
+      //     },
+      //     cacheRefresh: true,
+      //   });
+      //   const balance = parseInt(balances[nominatedAddress.address] ?? '0');
+      //   if (balance < NAMESPACE_MIN_NOMINATION_BALANCE) return;
+
+      //   await upgradeUserTier(
+      //     nominatedAddress!.User!.id!,
+      //     UserTierMap.ChainVerified,
+      //   );
+      // },
+      LaunchpadTokenTraded: async ({ payload }) => {
+        const { token_address } = payload;
+
+        const token = await models.LaunchpadToken.findOne({
+          where: {
+            token_address,
+            creator_address: {
+              [Op.ne]: null,
+            },
+          },
+        });
+        if (!token) {
+          log.warn(
+            `Token with creator not found for token address ${token_address}`,
+          );
+          return;
+        }
+
+        // check tier of token creator user
+        const tokenCreatorAddress = await models.Address.findOne({
+          where: {
+            address: token.creator_address!,
+          },
+          include: [
+            {
+              model: models.User,
+              required: true,
+            },
+          ],
+        });
+        if (!tokenCreatorAddress?.User) {
+          log.warn(
+            `Token creator user not found for address ${token.creator_address!}`,
+          );
+          return;
+        }
+        if (tokenCreatorAddress.User.tier >= UserTierMap.ChainVerified) return;
+
+        // if token has been traded above min amount, upgrade token creator to ChainVerified tier
+        const [totalTraded] = await models.sequelize.query<{
+          sum: number;
+        }>(
+          `SELECT SUM(community_token_amount) as sum FROM launchpad_trades WHERE token_address = :token_address`,
+          {
+            replacements: { token_address },
+            type: QueryTypes.SELECT,
+          },
+        );
+
+        if (totalTraded.sum > UPGRADE_MIN_TRADE_AMOUNT) {
+          await upgradeUserTier(
+            tokenCreatorAddress.User.id!,
+            UserTierMap.ChainVerified,
+          );
+        }
+      },
+      ContestContentAdded: async ({ payload }) => {
+        await onContestActivity(payload);
+      },
+      ContestContentUpvoted: async ({ payload }) => {
+        await onContestActivity(payload);
+      },
+    },
+  };
+}
+
+const upgradeUserTier = async (
+  userId: number,
+  toTier: number,
+  transaction?: Transaction,
+) => {
+  await models.User.update(
+    { tier: toTier },
+    {
+      where: {
+        id: userId,
+        tier: {
+          [Op.lt]: toTier,
+        },
+      },
+      transaction,
+    },
+  );
+};
+
+type ContestActivityPayload = {
+  contest_address: string;
+  contest_id?: number;
+};
+
+const onContestActivity = async ({
+  contest_address,
+  contest_id,
+}: ContestActivityPayload) => {
+  const contestManager = await findActiveContestManager(contest_address, {
+    include: [
+      {
+        model: models.Community,
+        required: true,
+        include: [
+          {
+            model: models.ChainNode,
+            required: true,
+          },
+        ],
+      },
+    ],
+  });
+  if (!contestManager?.Community?.ChainNode) {
+    log.warn(
+      `Active contest manager with chain node not found for contest address ${contest_address}`,
+    );
+    return;
+  }
+
+  const contestCreatorAddress = await models.Address.findOne({
+    where: {
+      address: contestManager.creator_address!,
+    },
+    include: [
+      {
+        model: models.User,
+        required: true,
+      },
+    ],
+  });
+  if (!contestCreatorAddress?.User) {
+    log.warn(
+      `Contest creator user not found for address ${contestManager.creator_address!}`,
+    );
+    return;
+  }
+
+  if (contestCreatorAddress.User.tier >= UserTierMap.ChainVerified) return;
+
+  const rpc = getChainNodeUrl({
+    url: contestManager!.Community!.ChainNode!.url,
+    private_url: contestManager!.Community!.ChainNode!.private_url,
+  });
+
+  const chain: commonProtocol.EvmProtocolChain = {
+    eth_chain_id: contestManager.Community!.ChainNode!.eth_chain_id!,
+    rpc,
+  };
+
+  const { contestBalance } = await commonProtocol.getContestScore(
+    chain,
+    contest_address,
+    contestManager.prize_percentage!,
+    contestManager.payout_structure,
+    contest_id,
+  );
+
+  const contestBalanceInt = BigInt(contestBalance ?? '0');
+
+  if (!contestBalanceInt) {
+    return;
+  }
+
+  // must be USDC or launchpad token
+
+  const isUSDC = [
+    USDC_BASE_MAINNET_ADDRESS,
+    USDC_BASE_SEPOLIA_ADDRESS,
+  ].includes(contestManager.funding_token_address!);
+
+  const isLaunchpadToken = !!(await models.LaunchpadToken.findOne({
+    where: {
+      // launchpad token addresses are all lowercase
+      token_address: contestManager.funding_token_address!.toLowerCase(),
+    },
+  }));
+
+  if (
+    (isUSDC && contestBalanceInt >= UPGRADE_MIN_USDC_BALANCE) ||
+    (isLaunchpadToken && contestBalanceInt >= UPGRADE_MIN_LAUNCHPAD_BALANCE)
+  ) {
+    // contest has been funded, upgrade contest creator to ChainVerified tier
+    await upgradeUserTier(
+      contestCreatorAddress.User.id!,
+      UserTierMap.ChainVerified,
+    );
+  } else {
+    log.warn(
+      `Contest funding token not approved for contest ${contest_address}`,
+    );
+    return;
+  }
+};
diff --git a/libs/model/src/models/community.ts b/libs/model/src/models/community.ts
index f2a8809208a..d5b8d8ec36d 100644
--- a/libs/model/src/models/community.ts
+++ b/libs/model/src/models/community.ts
@@ -137,6 +137,11 @@ export default (
         type: Sequelize.STRING,
         allowNull: true,
       },
+      namespace_verified: {
+        type: Sequelize.BOOLEAN,
+        allowNull: false,
+        defaultValue: false,
+      },
       created_at: { type: Sequelize.DATE, allowNull: true },
       updated_at: { type: Sequelize.DATE, allowNull: true },
       redirect: { type: Sequelize.TEXT, allowNull: true },
diff --git a/libs/model/src/services/evmChainEvents/chain-event-utils.ts b/libs/model/src/services/evmChainEvents/chain-event-utils.ts
index a80e6cc3c59..4e1e890f835 100644
--- a/libs/model/src/services/evmChainEvents/chain-event-utils.ts
+++ b/libs/model/src/services/evmChainEvents/chain-event-utils.ts
@@ -308,9 +308,11 @@ export const chainEventMappers: Record<string, EvmMapper<Events>> = {
   [EvmEventSignatures.NamespaceFactory.NamespaceDeployedWithReferral]:
     referralNamespaceDeployedMapper,
 
-  // Contests
+  // Namespace Factory
   [EvmEventSignatures.NamespaceFactory.ContestManagerDeployed]:
     contestManagerDeployedMapper,
+
+  // Contests
   [EvmEventSignatures.Contests.RecurringContestStarted]:
     recurringContestStartedMapper,
   [EvmEventSignatures.Contests.SingleContestStarted]:
diff --git a/libs/model/src/tester/e2eSeeds.ts b/libs/model/src/tester/e2eSeeds.ts
index 5c22a26a76e..be359cf538c 100644
--- a/libs/model/src/tester/e2eSeeds.ts
+++ b/libs/model/src/tester/e2eSeeds.ts
@@ -142,6 +142,7 @@ export const e2eTestEntities = async function (
           has_homepage: 'false' as any,
           collapsed_on_homepage: false,
           directory_page_enabled: false,
+          namespace_verified: false,
         })),
       )),
     );
diff --git a/libs/model/src/tester/seedDb.ts b/libs/model/src/tester/seedDb.ts
index 2f6e1a55ee6..f3aa74593c4 100644
--- a/libs/model/src/tester/seedDb.ts
+++ b/libs/model/src/tester/seedDb.ts
@@ -144,6 +144,7 @@ export const seedDb = async () => {
           type: ChainType.Token,
           base: ChainBase.Ethereum,
           chain_node_id: testnetNode.id!,
+          namespace_verified: false,
         },
         {
           id: 'yearn',
@@ -157,6 +158,7 @@ export const seedDb = async () => {
           type: ChainType.Token,
           base: ChainBase.Ethereum,
           chain_node_id: mainnetNode.id!,
+          namespace_verified: false,
         },
         {
           id: 'sushi',
@@ -171,6 +173,7 @@ export const seedDb = async () => {
           type: ChainType.Token,
           base: ChainBase.Ethereum,
           chain_node_id: mainnetNode.id!,
+          namespace_verified: false,
         },
         {
           id: 'edgeware',
@@ -185,6 +188,7 @@ export const seedDb = async () => {
           base: ChainBase.Substrate,
           ss58_prefix: 7,
           chain_node_id: edgewareNode.id!,
+          namespace_verified: false,
         },
         {
           id: 'ethereum',
@@ -199,6 +203,7 @@ export const seedDb = async () => {
           type: ChainType.Chain,
           base: ChainBase.Ethereum,
           chain_node_id: mainnetNode.id!,
+          namespace_verified: false,
         },
         {
           id: 'osmosis',
@@ -213,6 +218,7 @@ export const seedDb = async () => {
           base: ChainBase.CosmosSDK,
           chain_node_id: osmosisNode.id!,
           bech32_prefix: 'osmo',
+          namespace_verified: false,
         },
         {
           id: 'csdk-beta',
@@ -227,6 +233,7 @@ export const seedDb = async () => {
           base: ChainBase.CosmosSDK,
           chain_node_id: csdkBetaNode.id!,
           bech32_prefix: 'cosmos',
+          namespace_verified: false,
         },
         {
           id: 'csdk',
@@ -241,6 +248,7 @@ export const seedDb = async () => {
           base: ChainBase.CosmosSDK,
           chain_node_id: csdkV1Node.id!,
           bech32_prefix: 'cosmos',
+          namespace_verified: false,
         },
         {
           id: 'common-protocol',
@@ -257,6 +265,7 @@ export const seedDb = async () => {
           chain_node_id: 1263,
           namespace: 'IanSpace',
           namespace_address: ZERO_ADDRESS,
+          namespace_verified: false,
         },
         {
           id: 'csdk-beta-local',
@@ -271,6 +280,7 @@ export const seedDb = async () => {
           base: ChainBase.CosmosSDK,
           chain_node_id: csdkBetaLocalNode.id!,
           bech32_prefix: 'cosmos',
+          namespace_verified: false,
         },
         {
           id: 'csdk-v1-local',
@@ -285,6 +295,7 @@ export const seedDb = async () => {
           base: ChainBase.CosmosSDK,
           chain_node_id: csdkV1LocalNode.id!,
           bech32_prefix: 'cosmos',
+          namespace_verified: false,
         },
         {
           id: 'evmos-dev-local',
@@ -299,6 +310,7 @@ export const seedDb = async () => {
           base: ChainBase.CosmosSDK,
           chain_node_id: ethermintLocalNode.id!,
           bech32_prefix: 'cosmos',
+          namespace_verified: false,
         },
       ].map((x) => ({
         ...x,
@@ -310,6 +322,7 @@ export const seedDb = async () => {
         has_homepage: 'false' as any,
         collapsed_on_homepage: false,
         directory_page_enabled: false,
+        namespace_verified: false,
       })),
     );
 
diff --git a/libs/model/src/utils/findActiveContestManager.ts b/libs/model/src/utils/findActiveContestManager.ts
index 46be3fa49d9..346b7df18ba 100644
--- a/libs/model/src/utils/findActiveContestManager.ts
+++ b/libs/model/src/utils/findActiveContestManager.ts
@@ -1,8 +1,17 @@
-import { Op } from 'sequelize';
+import { FindOptions, Op } from 'sequelize';
 import { config } from '../config';
 import { models } from '../database';
 
-export async function findActiveContestManager(contest_address: string) {
+export async function findActiveContestManager(
+  contest_address: string,
+  {
+    include,
+  }: {
+    include?: FindOptions<typeof models.ContestManager>['include'];
+  } = {
+    include: [],
+  },
+) {
   return await models.ContestManager.findOne({
     where: {
       contest_address,
@@ -14,5 +23,6 @@ export async function findActiveContestManager(contest_address: string) {
         [Op.not]: true,
       },
     },
+    include,
   });
 }
diff --git a/libs/model/test/user/upgrade-tier-lifecycle.spec.ts b/libs/model/test/user/upgrade-tier-lifecycle.spec.ts
new file mode 100644
index 00000000000..3b6b3edcbec
--- /dev/null
+++ b/libs/model/test/user/upgrade-tier-lifecycle.spec.ts
@@ -0,0 +1,223 @@
+import { config, dispose } from '@hicommonwealth/core';
+import { commonProtocol } from '@hicommonwealth/evm-protocols';
+import { emitEvent, tokenBalanceCache } from '@hicommonwealth/model';
+import { User } from '@hicommonwealth/schemas';
+import { UserTierMap } from '@hicommonwealth/shared';
+import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest';
+import { z } from 'zod';
+import {
+  UPGRADE_MIN_USDC_BALANCE,
+  UpgradeTierPolicy,
+} from '../../src/aggregates/user/UpgradeTier.policy';
+import { models } from '../../src/database';
+import { USDC_BASE_MAINNET_ADDRESS } from '../../src/services/openai/parseBotCommand';
+import { seed } from '../../src/tester';
+import { drainOutbox } from '../utils';
+
+// const buildNamespaceTransferSingleEvent = (
+//   contractAddress: string,
+//   from: string,
+//   to: string,
+//   id: string,
+//   value: string,
+// ) => ({
+//   event_name: 'NamespaceTransferSingle',
+//   event_payload: {
+//     eventSource: {
+//       ethChainId: 1,
+//     },
+//     rawLog: {
+//       address: contractAddress,
+//       topics: ['0xabcdef', '0x123456'],
+//       data: '0xdata',
+//       blockNumber: '123456' as unknown as bigint,
+//       blockHash: '0xblockhash',
+//       transactionIndex: 0,
+//       removed: false,
+//       transactionHash: '0xtxhash',
+//       logIndex: 0,
+//     },
+//     block: {
+//       number: '123456' as unknown as bigint,
+//       timestamp: '1678901234' as unknown as bigint,
+//       hash: '0xblockhash',
+//       logsBloom: '0xlogsbloom',
+//       parentHash: '0xparenthash',
+//       miner: '0xminer',
+//       gasLimit: '10000000' as unknown as bigint,
+//     },
+//     parsedArgs: {
+//       operator: '0x1234567890123456789012345678901234567890' as `0x${string}`,
+//       from: from as `0x${string}`,
+//       to: to as `0x${string}`,
+//       id: id as unknown as bigint,
+//       value: value as unknown as bigint,
+//     },
+//   },
+// });
+
+describe('Upgrade Tiers lifecycle', () => {
+  const userAddress: string = '0x8888888888888888888888888888888888888888';
+  const contestAddress: string = '0x1234567890123456789012345678901234567890';
+  const contestId: number = 1;
+
+  // let community: z.infer<typeof Community>;
+  let user: z.infer<typeof User>;
+
+  beforeAll(async () => {
+    const [node1] = await seed('ChainNode', {
+      id: 1,
+      eth_chain_id: 1,
+    });
+    const [user1] = await seed('User', {
+      tier: UserTierMap.IncompleteUser,
+    });
+    const [community1] = await seed('Community', {
+      chain_node_id: node1!.id,
+      profile_count: 1,
+      namespace_address: '0xFFF1234567890123456789012345678901234567',
+      Addresses: [
+        {
+          role: 'member',
+          address: userAddress,
+          user_id: user1!.id,
+          verified: new Date(),
+          last_active: new Date().toISOString(),
+        },
+      ],
+      namespace_verified: false,
+    });
+
+    const [topic1] = await seed('Topic', {
+      id: 1,
+      community_id: community1!.id,
+    });
+
+    await models.ContestManager.create({
+      name: 'hello',
+      community_id: community1!.id,
+      payout_structure: [],
+      ticker: 'ABC',
+      decimals: 0,
+      contest_address: contestAddress,
+      cancelled: false,
+      ended: false,
+      is_farcaster_contest: false,
+      topic_id: topic1!.id,
+      interval: 0,
+      funding_token_address: USDC_BASE_MAINNET_ADDRESS,
+      contests: [
+        {
+          contest_address: contestAddress,
+          contest_id: contestId,
+          start_time: new Date(),
+          end_time: new Date(new Date().getTime() + 60 * 60 * 1000),
+          score: [],
+        },
+      ],
+      environment: config.APP_ENV,
+      creator_address: userAddress,
+      created_at: new Date(),
+    });
+
+    user = user1!;
+    // community = community1!;
+
+    vi.spyOn(tokenBalanceCache, 'getBalances').mockResolvedValue({
+      [userAddress]: '5',
+    });
+  });
+
+  afterAll(async () => {
+    await dispose()();
+    vi.restoreAllMocks();
+  });
+
+  // describe('Nomination Upgrade Policy', async () => {
+  //   test('should upgrade user to ChainVerified tier when 5 or more nomination tokens are held', async () => {
+  //     const userBefore = await models.User.findByPk(user.id);
+  //     expect(userBefore?.tier).toBe(UserTierMap.IncompleteUser);
+  //     const communityBefore = await models.Community.findByPk(community.id);
+  //     expect(communityBefore?.namespace_verified).toBe(false);
+
+  //     await emitEvent(models.Outbox, [
+  //       buildNamespaceTransferSingleEvent(
+  //         community.namespace_address!,
+  //         ZERO_ADDRESS,
+  //         userAddress,
+  //         NAMESPACE_COMMUNITY_NOMINATION_TOKEN_ID.toString(),
+  //         NAMESPACE_MIN_NOMINATION_BALANCE.toString(),
+  //       ) as EventPair<'NamespaceTransferSingle'>,
+  //     ]);
+
+  //     await drainOutbox(['NamespaceTransferSingle'], UpgradeTierPolicy);
+
+  //     const userAfter = await models.User.findByPk(user.id);
+  //     expect(userAfter?.tier).toBe(UserTierMap.ChainVerified);
+  //     const communityAfter = await models.Community.findByPk(community.id);
+  //     expect(communityAfter?.namespace_verified).toBe(true);
+  //   });
+  // });
+
+  describe('Contest Upgrade Policy', async () => {
+    test('should upgrade user to ChainVerified tier when contest is funded and there is contest activity', async () => {
+      await models.User.update(
+        { tier: UserTierMap.IncompleteUser },
+        { where: { id: user.id } },
+      );
+
+      const userBefore = await models.User.findByPk(user.id);
+      expect(userBefore?.tier).toBe(UserTierMap.IncompleteUser);
+
+      vi.spyOn(commonProtocol, 'getContestScore').mockResolvedValue({
+        contestBalance: (UPGRADE_MIN_USDC_BALANCE - 1n).toString(),
+        scores: [],
+      });
+
+      await emitEvent(models.Outbox, [
+        {
+          event_name: 'ContestContentAdded',
+          event_payload: {
+            contest_address: contestAddress,
+            creator_address: userAddress,
+            content_id: 1,
+            content_url: 'thread-id-1',
+          },
+        },
+      ]);
+
+      await drainOutbox(['ContestContentAdded'], UpgradeTierPolicy);
+
+      const userAfter = await models.User.findByPk(user.id);
+      expect(
+        userAfter!.tier,
+        'user tier should not change since contest balance is insufficient',
+      ).toBe(UserTierMap.IncompleteUser);
+
+      vi.spyOn(commonProtocol, 'getContestScore').mockResolvedValue({
+        contestBalance: UPGRADE_MIN_USDC_BALANCE.toString(),
+        scores: [],
+      });
+
+      await emitEvent(models.Outbox, [
+        {
+          event_name: 'ContestContentUpvoted',
+          event_payload: {
+            contest_address: contestAddress,
+            content_id: 1,
+            voting_power: '1',
+            voter_address: userAddress,
+          },
+        },
+      ]);
+
+      await drainOutbox(['ContestContentUpvoted'], UpgradeTierPolicy);
+
+      const userAfter2 = await models.User.findByPk(user.id);
+      expect(
+        userAfter2!.tier,
+        'balance is sufficient, user should be upgraded to ChainVerified tier',
+      ).toBe(UserTierMap.ChainVerified);
+    });
+  });
+});
diff --git a/libs/schemas/src/entities/community.schemas.ts b/libs/schemas/src/entities/community.schemas.ts
index a612c0f2cab..954ffd54566 100644
--- a/libs/schemas/src/entities/community.schemas.ts
+++ b/libs/schemas/src/entities/community.schemas.ts
@@ -64,6 +64,7 @@ export const Community = z.object({
   namespace: z.string().nullish(),
   namespace_address: z.string().nullish(),
   namespace_creator_address: z.string().nullish(),
+  namespace_verified: z.boolean().optional(),
   redirect: z.string().nullish(),
   snapshot_spaces: z.array(z.string().max(255)).default([]),
   include_in_digest_email: z.boolean().nullish(),
diff --git a/libs/shared/src/constants.ts b/libs/shared/src/constants.ts
index 7225ff5adf2..c63f8b63482 100644
--- a/libs/shared/src/constants.ts
+++ b/libs/shared/src/constants.ts
@@ -51,3 +51,6 @@ export const UNISWAP_CONVENIENCE_FEE_PERCENT = 85; // or 85 Basis Points
 export const UNISWAP_CONVENIENCE_FEE_RECIPIENT_ADDRESS = `0xD9CC5e4f556F4069618b5BA28033e81efd40F431`;
 
 export const WEI_PER_ETHER = 1000000000000000000;
+
+export const NAMESPACE_COMMUNITY_NOMINATION_TOKEN_ID = 3;
+export const NAMESPACE_MIN_NOMINATION_BALANCE = 5;
diff --git a/packages/commonwealth/server/migrations/20250417130713-add-community-namespace-improvements.js b/packages/commonwealth/server/migrations/20250417130713-add-community-namespace-improvements.js
new file mode 100644
index 00000000000..4407729b0ac
--- /dev/null
+++ b/packages/commonwealth/server/migrations/20250417130713-add-community-namespace-improvements.js
@@ -0,0 +1,34 @@
+'use strict';
+
+/** @type {import('sequelize-cli').Migration} */
+module.exports = {
+  async up(queryInterface, Sequelize) {
+    await queryInterface.sequelize.transaction(async (transaction) => {
+      await queryInterface.addColumn('Communities', 'namespace_verified', {
+        type: Sequelize.BOOLEAN,
+        allowNull: false,
+        defaultValue: false,
+        transaction,
+      });
+
+      await queryInterface.addIndex('Communities', ['namespace_address'], {
+        name: 'communities_namespace_address_idx',
+        transaction,
+      });
+    });
+  },
+
+  async down(queryInterface, Sequelize) {
+    await queryInterface.sequelize.transaction(async (transaction) => {
+      await queryInterface.removeIndex(
+        'Communities',
+        'communities_namespace_address_idx',
+        { transaction },
+      );
+
+      await queryInterface.removeColumn('Communities', 'namespace_verified', {
+        transaction,
+      });
+    });
+  },
+};


Reviews:
user: login: Rotorsoft
body: LGTM 🚀 

You can also move the mutations to internal commands and call them from inside the policy.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T20:23:44Z
user: login: timolegros
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T12:39:42Z
user: login: rbentcw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T18:59:08Z
user: login: timolegros
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T21:16:39Z
user: login: rbentcw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T21:30:40Z
user: login: timolegros
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-17T22:08:15Z

Review comments:
diff_hunk: @@ -0,0 +1,269 @@
+import { logger, Policy } from '@hicommonwealth/core';
+import { commonProtocol } from '@hicommonwealth/evm-protocols';
+import { events } from '@hicommonwealth/schemas';
+import {
+  BalanceSourceType,
+  NAMESPACE_COMMUNITY_NOMINATION_TOKEN_ID,
+  ZERO_ADDRESS,
+} from '@hicommonwealth/shared';
+import { Op, QueryTypes } from 'sequelize';
+import { models } from '../../database';
+import { tokenBalanceCache } from '../../services';
+import {
+  USDC_BASE_MAINNET_ADDRESS,
+  USDC_BASE_SEPOLIA_ADDRESS,
+} from '../../services/openai/parseBotCommand';
+import { findActiveContestManager } from '../../utils/findActiveContestManager';
+import { getChainNodeUrl } from '../../utils/utils';
+
+export const UPGRADE_MIN_TRADE_AMOUNT = 250_000_000_000_000_000n; // 2.5 * 10^17
+export const UPGRADE_MIN_USDC_BALANCE = 50_000_000_000_000_000_000n; // 50 * 10^18
+export const UPGRADE_MIN_LAUNCHPAD_BALANCE = 1_000_000_000_000_000_000n; // 1 * 10^18
+
+const log = logger(import.meta);
+
+const inputs = {
+  NamespaceTransferSingle: events.NamespaceTransferSingle,
+  LaunchpadTokenTraded: events.LaunchpadTokenTraded,
+  ContestContentAdded: events.ContestContentAdded,
+  ContestContentUpvoted: events.ContestContentUpvoted,
+};
+
+export function UpgradeTierPolicy(): Policy<typeof inputs> {
+  return {
+    inputs,
+    body: {
+      NamespaceTransferSingle: async ({ payload }) => {
+        const {
+          parsedArgs: { from, to: userAddress, id: tokenId },
+          rawLog: { address: namespaceAddress },
+        } = payload;
+        if (tokenId !== 3n) return; // must be community nomination token
+
+        if (from !== ZERO_ADDRESS) return; // must be minted
+
+        const community = await models.Community.findOne({
+          where: { namespace_address: namespaceAddress },
+          include: [
+            {
+              model: models.ChainNode,
path: libs/model/src/aggregates/user/UpgradeTier.policy.ts
user: login: timolegros
body: It seems like this is only used to get the `eth_chain_id` for balance fetching. You can get the `eth_chain_id` from `event.eventSource.ethChainId` so you can avoid the join with "ChainNodes".
created_at: 2025-04-10T12:09:24Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,269 @@
+import { logger, Policy } from '@hicommonwealth/core';
+import { commonProtocol } from '@hicommonwealth/evm-protocols';
+import { events } from '@hicommonwealth/schemas';
+import {
+  BalanceSourceType,
+  NAMESPACE_COMMUNITY_NOMINATION_TOKEN_ID,
+  ZERO_ADDRESS,
+} from '@hicommonwealth/shared';
+import { Op, QueryTypes } from 'sequelize';
+import { models } from '../../database';
+import { tokenBalanceCache } from '../../services';
+import {
+  USDC_BASE_MAINNET_ADDRESS,
+  USDC_BASE_SEPOLIA_ADDRESS,
+} from '../../services/openai/parseBotCommand';
+import { findActiveContestManager } from '../../utils/findActiveContestManager';
+import { getChainNodeUrl } from '../../utils/utils';
+
+export const UPGRADE_MIN_TRADE_AMOUNT = 250_000_000_000_000_000n; // 2.5 * 10^17
+export const UPGRADE_MIN_USDC_BALANCE = 50_000_000_000_000_000_000n; // 50 * 10^18
+export const UPGRADE_MIN_LAUNCHPAD_BALANCE = 1_000_000_000_000_000_000n; // 1 * 10^18
+
+const log = logger(import.meta);
+
+const inputs = {
+  NamespaceTransferSingle: events.NamespaceTransferSingle,
+  LaunchpadTokenTraded: events.LaunchpadTokenTraded,
+  ContestContentAdded: events.ContestContentAdded,
+  ContestContentUpvoted: events.ContestContentUpvoted,
+};
+
+export function UpgradeTierPolicy(): Policy<typeof inputs> {
+  return {
+    inputs,
+    body: {
+      NamespaceTransferSingle: async ({ payload }) => {
+        const {
+          parsedArgs: { from, to: userAddress, id: tokenId },
+          rawLog: { address: namespaceAddress },
+        } = payload;
+        if (tokenId !== 3n) return; // must be community nomination token
+
+        if (from !== ZERO_ADDRESS) return; // must be minted
+
+        const community = await models.Community.findOne({
+          where: { namespace_address: namespaceAddress },
+          include: [
+            {
+              model: models.ChainNode,
+              required: true,
+            },
+          ],
+        });
+        if (!community) {
+          log.warn(
+            `Community not found for namespace address ${namespaceAddress}`,
+          );
+          return;
+        }
+
+        const nominatedAddress = await models.Address.findOne({
+          where: { address: userAddress },
+          include: [
+            {
+              model: models.User,
+              required: true,
+            },
+          ],
+        });
+        if (!nominatedAddress?.User) {
+          log.warn(`User not found for address ${userAddress}`);
+          return;
+        }
+        if (nominatedAddress.User.tier >= 4) return;
path: libs/model/src/aggregates/user/UpgradeTier.policy.ts
user: login: timolegros
body: Should use a value from the `UserTierMap` which is exported from `@hicommonwealth/shared`. In this case it should be `UserTierMap.ChainVerified` (this value is 5 and no longer 4).
created_at: 2025-04-10T12:11:13Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,269 @@
+import { logger, Policy } from '@hicommonwealth/core';
+import { commonProtocol } from '@hicommonwealth/evm-protocols';
+import { events } from '@hicommonwealth/schemas';
+import {
+  BalanceSourceType,
+  NAMESPACE_COMMUNITY_NOMINATION_TOKEN_ID,
+  ZERO_ADDRESS,
+} from '@hicommonwealth/shared';
+import { Op, QueryTypes } from 'sequelize';
+import { models } from '../../database';
+import { tokenBalanceCache } from '../../services';
+import {
+  USDC_BASE_MAINNET_ADDRESS,
+  USDC_BASE_SEPOLIA_ADDRESS,
+} from '../../services/openai/parseBotCommand';
+import { findActiveContestManager } from '../../utils/findActiveContestManager';
+import { getChainNodeUrl } from '../../utils/utils';
+
+export const UPGRADE_MIN_TRADE_AMOUNT = 250_000_000_000_000_000n; // 2.5 * 10^17
+export const UPGRADE_MIN_USDC_BALANCE = 50_000_000_000_000_000_000n; // 50 * 10^18
+export const UPGRADE_MIN_LAUNCHPAD_BALANCE = 1_000_000_000_000_000_000n; // 1 * 10^18
+
+const log = logger(import.meta);
+
+const inputs = {
+  NamespaceTransferSingle: events.NamespaceTransferSingle,
+  LaunchpadTokenTraded: events.LaunchpadTokenTraded,
+  ContestContentAdded: events.ContestContentAdded,
+  ContestContentUpvoted: events.ContestContentUpvoted,
+};
+
+export function UpgradeTierPolicy(): Policy<typeof inputs> {
+  return {
+    inputs,
+    body: {
+      NamespaceTransferSingle: async ({ payload }) => {
+        const {
+          parsedArgs: { from, to: userAddress, id: tokenId },
+          rawLog: { address: namespaceAddress },
+        } = payload;
+        if (tokenId !== 3n) return; // must be community nomination token
+
+        if (from !== ZERO_ADDRESS) return; // must be minted
+
+        const community = await models.Community.findOne({
+          where: { namespace_address: namespaceAddress },
+          include: [
+            {
+              model: models.ChainNode,
+              required: true,
+            },
+          ],
+        });
+        if (!community) {
+          log.warn(
+            `Community not found for namespace address ${namespaceAddress}`,
+          );
+          return;
+        }
+
+        const nominatedAddress = await models.Address.findOne({
+          where: { address: userAddress },
+          include: [
+            {
+              model: models.User,
+              required: true,
+            },
+          ],
+        });
+        if (!nominatedAddress?.User) {
+          log.warn(`User not found for address ${userAddress}`);
+          return;
+        }
+        if (nominatedAddress.User.tier >= 4) return;
+
+        // if user has sufficient balance of community nomination token, upgrade to tier 4
+        const balances = await tokenBalanceCache.getBalances({
+          addresses: [nominatedAddress.address],
+          balanceSourceType: BalanceSourceType.ERC1155,
+          sourceOptions: {
+            evmChainId: community.ChainNode!.eth_chain_id!,
+            contractAddress: community.namespace_address!,
+            tokenId: NAMESPACE_COMMUNITY_NOMINATION_TOKEN_ID,
+          },
+          cacheRefresh: true,
+        });
+        const balance = parseInt(balances[nominatedAddress.address] ?? '0');
+        if (balance < 5) return;
+
+        await upgradeUserTier(nominatedAddress.User.id!, 4);
path: libs/model/src/aggregates/user/UpgradeTier.policy.ts
user: login: timolegros
body: Same here
created_at: 2025-04-10T12:11:32Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,269 @@
+import { logger, Policy } from '@hicommonwealth/core';
+import { commonProtocol } from '@hicommonwealth/evm-protocols';
+import { events } from '@hicommonwealth/schemas';
+import {
+  BalanceSourceType,
+  NAMESPACE_COMMUNITY_NOMINATION_TOKEN_ID,
+  ZERO_ADDRESS,
+} from '@hicommonwealth/shared';
+import { Op, QueryTypes } from 'sequelize';
+import { models } from '../../database';
+import { tokenBalanceCache } from '../../services';
+import {
+  USDC_BASE_MAINNET_ADDRESS,
+  USDC_BASE_SEPOLIA_ADDRESS,
+} from '../../services/openai/parseBotCommand';
+import { findActiveContestManager } from '../../utils/findActiveContestManager';
+import { getChainNodeUrl } from '../../utils/utils';
+
+export const UPGRADE_MIN_TRADE_AMOUNT = 250_000_000_000_000_000n; // 2.5 * 10^17
+export const UPGRADE_MIN_USDC_BALANCE = 50_000_000_000_000_000_000n; // 50 * 10^18
+export const UPGRADE_MIN_LAUNCHPAD_BALANCE = 1_000_000_000_000_000_000n; // 1 * 10^18
+
+const log = logger(import.meta);
+
+const inputs = {
+  NamespaceTransferSingle: events.NamespaceTransferSingle,
+  LaunchpadTokenTraded: events.LaunchpadTokenTraded,
+  ContestContentAdded: events.ContestContentAdded,
+  ContestContentUpvoted: events.ContestContentUpvoted,
+};
+
+export function UpgradeTierPolicy(): Policy<typeof inputs> {
+  return {
+    inputs,
+    body: {
+      NamespaceTransferSingle: async ({ payload }) => {
+        const {
+          parsedArgs: { from, to: userAddress, id: tokenId },
+          rawLog: { address: namespaceAddress },
+        } = payload;
+        if (tokenId !== 3n) return; // must be community nomination token
+
+        if (from !== ZERO_ADDRESS) return; // must be minted
+
+        const community = await models.Community.findOne({
+          where: { namespace_address: namespaceAddress },
+          include: [
+            {
+              model: models.ChainNode,
+              required: true,
+            },
+          ],
+        });
+        if (!community) {
+          log.warn(
+            `Community not found for namespace address ${namespaceAddress}`,
+          );
+          return;
+        }
+
+        const nominatedAddress = await models.Address.findOne({
+          where: { address: userAddress },
+          include: [
+            {
+              model: models.User,
+              required: true,
+            },
+          ],
+        });
+        if (!nominatedAddress?.User) {
+          log.warn(`User not found for address ${userAddress}`);
+          return;
+        }
+        if (nominatedAddress.User.tier >= 4) return;
+
+        // if user has sufficient balance of community nomination token, upgrade to tier 4
+        const balances = await tokenBalanceCache.getBalances({
+          addresses: [nominatedAddress.address],
+          balanceSourceType: BalanceSourceType.ERC1155,
+          sourceOptions: {
+            evmChainId: community.ChainNode!.eth_chain_id!,
+            contractAddress: community.namespace_address!,
+            tokenId: NAMESPACE_COMMUNITY_NOMINATION_TOKEN_ID,
+          },
+          cacheRefresh: true,
+        });
+        const balance = parseInt(balances[nominatedAddress.address] ?? '0');
+        if (balance < 5) return;
path: libs/model/src/aggregates/user/UpgradeTier.policy.ts
user: login: timolegros
body: Can we do `<= 4` and then update `4` to use a shared constant with the `4` values used in #11811 (assuming these are related)?
created_at: 2025-04-10T12:13:04Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,269 @@
+import { logger, Policy } from '@hicommonwealth/core';
+import { commonProtocol } from '@hicommonwealth/evm-protocols';
+import { events } from '@hicommonwealth/schemas';
+import {
+  BalanceSourceType,
+  NAMESPACE_COMMUNITY_NOMINATION_TOKEN_ID,
+  ZERO_ADDRESS,
+} from '@hicommonwealth/shared';
+import { Op, QueryTypes } from 'sequelize';
+import { models } from '../../database';
+import { tokenBalanceCache } from '../../services';
+import {
+  USDC_BASE_MAINNET_ADDRESS,
+  USDC_BASE_SEPOLIA_ADDRESS,
+} from '../../services/openai/parseBotCommand';
+import { findActiveContestManager } from '../../utils/findActiveContestManager';
+import { getChainNodeUrl } from '../../utils/utils';
+
+export const UPGRADE_MIN_TRADE_AMOUNT = 250_000_000_000_000_000n; // 2.5 * 10^17
+export const UPGRADE_MIN_USDC_BALANCE = 50_000_000_000_000_000_000n; // 50 * 10^18
+export const UPGRADE_MIN_LAUNCHPAD_BALANCE = 1_000_000_000_000_000_000n; // 1 * 10^18
+
+const log = logger(import.meta);
+
+const inputs = {
+  NamespaceTransferSingle: events.NamespaceTransferSingle,
+  LaunchpadTokenTraded: events.LaunchpadTokenTraded,
+  ContestContentAdded: events.ContestContentAdded,
+  ContestContentUpvoted: events.ContestContentUpvoted,
+};
+
+export function UpgradeTierPolicy(): Policy<typeof inputs> {
+  return {
+    inputs,
+    body: {
+      NamespaceTransferSingle: async ({ payload }) => {
+        const {
+          parsedArgs: { from, to: userAddress, id: tokenId },
+          rawLog: { address: namespaceAddress },
+        } = payload;
+        if (tokenId !== 3n) return; // must be community nomination token
+
+        if (from !== ZERO_ADDRESS) return; // must be minted
+
+        const community = await models.Community.findOne({
+          where: { namespace_address: namespaceAddress },
+          include: [
+            {
+              model: models.ChainNode,
+              required: true,
+            },
+          ],
+        });
+        if (!community) {
+          log.warn(
+            `Community not found for namespace address ${namespaceAddress}`,
+          );
+          return;
+        }
+
+        const nominatedAddress = await models.Address.findOne({
+          where: { address: userAddress },
+          include: [
+            {
+              model: models.User,
+              required: true,
+            },
+          ],
+        });
+        if (!nominatedAddress?.User) {
+          log.warn(`User not found for address ${userAddress}`);
+          return;
+        }
+        if (nominatedAddress.User.tier >= 4) return;
+
+        // if user has sufficient balance of community nomination token, upgrade to tier 4
+        const balances = await tokenBalanceCache.getBalances({
+          addresses: [nominatedAddress.address],
+          balanceSourceType: BalanceSourceType.ERC1155,
+          sourceOptions: {
+            evmChainId: community.ChainNode!.eth_chain_id!,
+            contractAddress: community.namespace_address!,
+            tokenId: NAMESPACE_COMMUNITY_NOMINATION_TOKEN_ID,
+          },
+          cacheRefresh: true,
+        });
+        const balance = parseInt(balances[nominatedAddress.address] ?? '0');
+        if (balance < 5) return;
+
+        await upgradeUserTier(nominatedAddress.User.id!, 4);
+      },
+      LaunchpadTokenTraded: async ({ payload }) => {
+        const { token_address } = payload;
+
+        const token = await models.LaunchpadToken.findOne({
+          where: {
+            token_address,
+            creator_address: {
+              [Op.ne]: null,
+            },
+          },
+        });
+        if (!token) {
+          log.warn(
+            `Token with creator not found for token address ${token_address}`,
+          );
+          return;
+        }
+
+        // check tier of token creator user
+        const tokenCreatorAddress = await models.Address.findOne({
+          where: {
+            address: token.creator_address!,
+          },
+          include: [
+            {
+              model: models.User,
+              required: true,
+            },
+          ],
+        });
+        if (!tokenCreatorAddress?.User) {
+          log.warn(
+            `Token creator user not found for address ${token.creator_address!}`,
+          );
+          return;
+        }
+        if (tokenCreatorAddress.User.tier >= 4) return;
path: libs/model/src/aggregates/user/UpgradeTier.policy.ts
user: login: timolegros
body: `UserTierMap.ChainVerified`
created_at: 2025-04-10T12:18:02Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,269 @@
+import { logger, Policy } from '@hicommonwealth/core';
+import { commonProtocol } from '@hicommonwealth/evm-protocols';
+import { events } from '@hicommonwealth/schemas';
+import {
+  BalanceSourceType,
+  NAMESPACE_COMMUNITY_NOMINATION_TOKEN_ID,
+  ZERO_ADDRESS,
+} from '@hicommonwealth/shared';
+import { Op, QueryTypes } from 'sequelize';
+import { models } from '../../database';
+import { tokenBalanceCache } from '../../services';
+import {
+  USDC_BASE_MAINNET_ADDRESS,
+  USDC_BASE_SEPOLIA_ADDRESS,
+} from '../../services/openai/parseBotCommand';
+import { findActiveContestManager } from '../../utils/findActiveContestManager';
+import { getChainNodeUrl } from '../../utils/utils';
+
+export const UPGRADE_MIN_TRADE_AMOUNT = 250_000_000_000_000_000n; // 2.5 * 10^17
+export const UPGRADE_MIN_USDC_BALANCE = 50_000_000_000_000_000_000n; // 50 * 10^18
+export const UPGRADE_MIN_LAUNCHPAD_BALANCE = 1_000_000_000_000_000_000n; // 1 * 10^18
+
+const log = logger(import.meta);
+
+const inputs = {
+  NamespaceTransferSingle: events.NamespaceTransferSingle,
+  LaunchpadTokenTraded: events.LaunchpadTokenTraded,
+  ContestContentAdded: events.ContestContentAdded,
+  ContestContentUpvoted: events.ContestContentUpvoted,
+};
+
+export function UpgradeTierPolicy(): Policy<typeof inputs> {
+  return {
+    inputs,
+    body: {
+      NamespaceTransferSingle: async ({ payload }) => {
+        const {
+          parsedArgs: { from, to: userAddress, id: tokenId },
+          rawLog: { address: namespaceAddress },
+        } = payload;
+        if (tokenId !== 3n) return; // must be community nomination token
+
+        if (from !== ZERO_ADDRESS) return; // must be minted
+
+        const community = await models.Community.findOne({
+          where: { namespace_address: namespaceAddress },
+          include: [
+            {
+              model: models.ChainNode,
+              required: true,
+            },
+          ],
+        });
+        if (!community) {
+          log.warn(
+            `Community not found for namespace address ${namespaceAddress}`,
+          );
+          return;
+        }
+
+        const nominatedAddress = await models.Address.findOne({
+          where: { address: userAddress },
+          include: [
+            {
+              model: models.User,
+              required: true,
+            },
+          ],
+        });
+        if (!nominatedAddress?.User) {
+          log.warn(`User not found for address ${userAddress}`);
+          return;
+        }
+        if (nominatedAddress.User.tier >= 4) return;
+
+        // if user has sufficient balance of community nomination token, upgrade to tier 4
+        const balances = await tokenBalanceCache.getBalances({
+          addresses: [nominatedAddress.address],
+          balanceSourceType: BalanceSourceType.ERC1155,
+          sourceOptions: {
+            evmChainId: community.ChainNode!.eth_chain_id!,
+            contractAddress: community.namespace_address!,
+            tokenId: NAMESPACE_COMMUNITY_NOMINATION_TOKEN_ID,
+          },
+          cacheRefresh: true,
+        });
+        const balance = parseInt(balances[nominatedAddress.address] ?? '0');
+        if (balance < 5) return;
+
+        await upgradeUserTier(nominatedAddress.User.id!, 4);
+      },
+      LaunchpadTokenTraded: async ({ payload }) => {
+        const { token_address } = payload;
+
+        const token = await models.LaunchpadToken.findOne({
+          where: {
+            token_address,
+            creator_address: {
+              [Op.ne]: null,
+            },
+          },
+        });
+        if (!token) {
+          log.warn(
+            `Token with creator not found for token address ${token_address}`,
+          );
+          return;
+        }
+
+        // check tier of token creator user
+        const tokenCreatorAddress = await models.Address.findOne({
+          where: {
+            address: token.creator_address!,
+          },
+          include: [
+            {
+              model: models.User,
+              required: true,
+            },
+          ],
+        });
+        if (!tokenCreatorAddress?.User) {
+          log.warn(
+            `Token creator user not found for address ${token.creator_address!}`,
+          );
+          return;
+        }
+        if (tokenCreatorAddress.User.tier >= 4) return;
+
+        // if token has been traded above min amount, upgrade token creator to tier 4
+        const [totalTraded] = await models.sequelize.query<{
+          sum: number;
+        }>(
+          `SELECT SUM(community_token_amount) as sum FROM launchpad_trades WHERE token_address = :token_address`,
+          {
+            replacements: { token_address },
+            type: QueryTypes.SELECT,
+          },
+        );
+
+        if (totalTraded.sum > UPGRADE_MIN_TRADE_AMOUNT) {
+          await upgradeUserTier(tokenCreatorAddress.User.id!, 4);
path: libs/model/src/aggregates/user/UpgradeTier.policy.ts
user: login: timolegros
body: Anotha one
created_at: 2025-04-10T12:18:58Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,269 @@
+import { logger, Policy } from '@hicommonwealth/core';
+import { commonProtocol } from '@hicommonwealth/evm-protocols';
+import { events } from '@hicommonwealth/schemas';
+import {
+  BalanceSourceType,
+  NAMESPACE_COMMUNITY_NOMINATION_TOKEN_ID,
+  ZERO_ADDRESS,
+} from '@hicommonwealth/shared';
+import { Op, QueryTypes } from 'sequelize';
+import { models } from '../../database';
+import { tokenBalanceCache } from '../../services';
+import {
+  USDC_BASE_MAINNET_ADDRESS,
+  USDC_BASE_SEPOLIA_ADDRESS,
+} from '../../services/openai/parseBotCommand';
+import { findActiveContestManager } from '../../utils/findActiveContestManager';
+import { getChainNodeUrl } from '../../utils/utils';
+
+export const UPGRADE_MIN_TRADE_AMOUNT = 250_000_000_000_000_000n; // 2.5 * 10^17
+export const UPGRADE_MIN_USDC_BALANCE = 50_000_000_000_000_000_000n; // 50 * 10^18
+export const UPGRADE_MIN_LAUNCHPAD_BALANCE = 1_000_000_000_000_000_000n; // 1 * 10^18
+
+const log = logger(import.meta);
+
+const inputs = {
+  NamespaceTransferSingle: events.NamespaceTransferSingle,
+  LaunchpadTokenTraded: events.LaunchpadTokenTraded,
+  ContestContentAdded: events.ContestContentAdded,
+  ContestContentUpvoted: events.ContestContentUpvoted,
+};
+
+export function UpgradeTierPolicy(): Policy<typeof inputs> {
+  return {
+    inputs,
+    body: {
+      NamespaceTransferSingle: async ({ payload }) => {
+        const {
+          parsedArgs: { from, to: userAddress, id: tokenId },
+          rawLog: { address: namespaceAddress },
+        } = payload;
+        if (tokenId !== 3n) return; // must be community nomination token
+
+        if (from !== ZERO_ADDRESS) return; // must be minted
+
+        const community = await models.Community.findOne({
+          where: { namespace_address: namespaceAddress },
+          include: [
+            {
+              model: models.ChainNode,
+              required: true,
+            },
+          ],
+        });
+        if (!community) {
+          log.warn(
+            `Community not found for namespace address ${namespaceAddress}`,
+          );
+          return;
+        }
+
+        const nominatedAddress = await models.Address.findOne({
+          where: { address: userAddress },
+          include: [
+            {
+              model: models.User,
+              required: true,
+            },
+          ],
+        });
+        if (!nominatedAddress?.User) {
+          log.warn(`User not found for address ${userAddress}`);
+          return;
+        }
+        if (nominatedAddress.User.tier >= 4) return;
+
+        // if user has sufficient balance of community nomination token, upgrade to tier 4
+        const balances = await tokenBalanceCache.getBalances({
+          addresses: [nominatedAddress.address],
+          balanceSourceType: BalanceSourceType.ERC1155,
+          sourceOptions: {
+            evmChainId: community.ChainNode!.eth_chain_id!,
+            contractAddress: community.namespace_address!,
+            tokenId: NAMESPACE_COMMUNITY_NOMINATION_TOKEN_ID,
+          },
+          cacheRefresh: true,
+        });
+        const balance = parseInt(balances[nominatedAddress.address] ?? '0');
+        if (balance < 5) return;
+
+        await upgradeUserTier(nominatedAddress.User.id!, 4);
+      },
+      LaunchpadTokenTraded: async ({ payload }) => {
+        const { token_address } = payload;
+
+        const token = await models.LaunchpadToken.findOne({
+          where: {
+            token_address,
+            creator_address: {
+              [Op.ne]: null,
+            },
+          },
+        });
+        if (!token) {
+          log.warn(
+            `Token with creator not found for token address ${token_address}`,
+          );
+          return;
+        }
+
+        // check tier of token creator user
+        const tokenCreatorAddress = await models.Address.findOne({
+          where: {
+            address: token.creator_address!,
+          },
+          include: [
+            {
+              model: models.User,
+              required: true,
+            },
+          ],
+        });
+        if (!tokenCreatorAddress?.User) {
+          log.warn(
+            `Token creator user not found for address ${token.creator_address!}`,
+          );
+          return;
+        }
+        if (tokenCreatorAddress.User.tier >= 4) return;
+
+        // if token has been traded above min amount, upgrade token creator to tier 4
+        const [totalTraded] = await models.sequelize.query<{
+          sum: number;
+        }>(
+          `SELECT SUM(community_token_amount) as sum FROM launchpad_trades WHERE token_address = :token_address`,
+          {
+            replacements: { token_address },
+            type: QueryTypes.SELECT,
+          },
+        );
+
+        if (totalTraded.sum > UPGRADE_MIN_TRADE_AMOUNT) {
+          await upgradeUserTier(tokenCreatorAddress.User.id!, 4);
+        }
+      },
+      ContestContentAdded: async ({ payload }) => {
+        await onContestActivity(payload);
+      },
+      ContestContentUpvoted: async ({ payload }) => {
+        await onContestActivity(payload);
+      },
+    },
+  };
+}
+
+const upgradeUserTier = async (userId: number, toTier: number) => {
+  await models.User.update(
+    { tier: toTier },
+    {
+      where: {
+        id: userId,
+        tier: {
+          [Op.lt]: toTier,
+        },
+      },
+    },
+  );
+};
+
+type ContestActivityPayload = {
+  contest_address: string;
+  contest_id?: number;
+};
+
+const onContestActivity = async ({
+  contest_address,
+  contest_id,
+}: ContestActivityPayload) => {
+  const contestManager = await findActiveContestManager(contest_address, {
+    include: [
+      {
+        model: models.Community,
+        required: true,
+        include: [
+          {
+            model: models.ChainNode,
+            required: true,
+          },
+        ],
+      },
+    ],
+  });
+  if (!contestManager?.Community?.ChainNode) {
+    log.warn(
+      `Active contest manager with chain node not found for contest address ${contest_address}`,
+    );
+    return;
+  }
+
+  const contestCreatorAddress = await models.Address.findOne({
+    where: {
+      address: contestManager.creator_address!,
+    },
+    include: [
+      {
+        model: models.User,
+        required: true,
+      },
+    ],
+  });
+  if (!contestCreatorAddress?.User) {
+    log.warn(
+      `Contest creator user not found for address ${contestManager.creator_address!}`,
+    );
+    return;
+  }
+
+  if (contestCreatorAddress.User.tier >= 4) return;
path: libs/model/src/aggregates/user/UpgradeTier.policy.ts
user: login: timolegros
body: ^
created_at: 2025-04-10T12:24:25Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,269 @@
+import { logger, Policy } from '@hicommonwealth/core';
+import { commonProtocol } from '@hicommonwealth/evm-protocols';
+import { events } from '@hicommonwealth/schemas';
+import {
+  BalanceSourceType,
+  NAMESPACE_COMMUNITY_NOMINATION_TOKEN_ID,
+  ZERO_ADDRESS,
+} from '@hicommonwealth/shared';
+import { Op, QueryTypes } from 'sequelize';
+import { models } from '../../database';
+import { tokenBalanceCache } from '../../services';
+import {
+  USDC_BASE_MAINNET_ADDRESS,
+  USDC_BASE_SEPOLIA_ADDRESS,
+} from '../../services/openai/parseBotCommand';
+import { findActiveContestManager } from '../../utils/findActiveContestManager';
+import { getChainNodeUrl } from '../../utils/utils';
+
+export const UPGRADE_MIN_TRADE_AMOUNT = 250_000_000_000_000_000n; // 2.5 * 10^17
+export const UPGRADE_MIN_USDC_BALANCE = 50_000_000_000_000_000_000n; // 50 * 10^18
+export const UPGRADE_MIN_LAUNCHPAD_BALANCE = 1_000_000_000_000_000_000n; // 1 * 10^18
+
+const log = logger(import.meta);
+
+const inputs = {
+  NamespaceTransferSingle: events.NamespaceTransferSingle,
+  LaunchpadTokenTraded: events.LaunchpadTokenTraded,
+  ContestContentAdded: events.ContestContentAdded,
+  ContestContentUpvoted: events.ContestContentUpvoted,
+};
+
+export function UpgradeTierPolicy(): Policy<typeof inputs> {
+  return {
+    inputs,
+    body: {
+      NamespaceTransferSingle: async ({ payload }) => {
+        const {
+          parsedArgs: { from, to: userAddress, id: tokenId },
+          rawLog: { address: namespaceAddress },
+        } = payload;
+        if (tokenId !== 3n) return; // must be community nomination token
+
+        if (from !== ZERO_ADDRESS) return; // must be minted
+
+        const community = await models.Community.findOne({
+          where: { namespace_address: namespaceAddress },
+          include: [
+            {
+              model: models.ChainNode,
+              required: true,
+            },
+          ],
+        });
+        if (!community) {
+          log.warn(
+            `Community not found for namespace address ${namespaceAddress}`,
+          );
+          return;
+        }
+
+        const nominatedAddress = await models.Address.findOne({
+          where: { address: userAddress },
+          include: [
+            {
+              model: models.User,
+              required: true,
+            },
+          ],
+        });
+        if (!nominatedAddress?.User) {
+          log.warn(`User not found for address ${userAddress}`);
+          return;
+        }
+        if (nominatedAddress.User.tier >= 4) return;
+
+        // if user has sufficient balance of community nomination token, upgrade to tier 4
+        const balances = await tokenBalanceCache.getBalances({
+          addresses: [nominatedAddress.address],
+          balanceSourceType: BalanceSourceType.ERC1155,
+          sourceOptions: {
+            evmChainId: community.ChainNode!.eth_chain_id!,
+            contractAddress: community.namespace_address!,
+            tokenId: NAMESPACE_COMMUNITY_NOMINATION_TOKEN_ID,
+          },
+          cacheRefresh: true,
+        });
+        const balance = parseInt(balances[nominatedAddress.address] ?? '0');
+        if (balance < 5) return;
+
+        await upgradeUserTier(nominatedAddress.User.id!, 4);
+      },
+      LaunchpadTokenTraded: async ({ payload }) => {
+        const { token_address } = payload;
+
+        const token = await models.LaunchpadToken.findOne({
+          where: {
+            token_address,
+            creator_address: {
+              [Op.ne]: null,
+            },
+          },
+        });
+        if (!token) {
+          log.warn(
+            `Token with creator not found for token address ${token_address}`,
+          );
+          return;
+        }
+
+        // check tier of token creator user
+        const tokenCreatorAddress = await models.Address.findOne({
+          where: {
+            address: token.creator_address!,
+          },
+          include: [
+            {
+              model: models.User,
+              required: true,
+            },
+          ],
+        });
+        if (!tokenCreatorAddress?.User) {
+          log.warn(
+            `Token creator user not found for address ${token.creator_address!}`,
+          );
+          return;
+        }
+        if (tokenCreatorAddress.User.tier >= 4) return;
+
+        // if token has been traded above min amount, upgrade token creator to tier 4
+        const [totalTraded] = await models.sequelize.query<{
+          sum: number;
+        }>(
+          `SELECT SUM(community_token_amount) as sum FROM launchpad_trades WHERE token_address = :token_address`,
+          {
+            replacements: { token_address },
+            type: QueryTypes.SELECT,
+          },
+        );
+
+        if (totalTraded.sum > UPGRADE_MIN_TRADE_AMOUNT) {
+          await upgradeUserTier(tokenCreatorAddress.User.id!, 4);
+        }
+      },
+      ContestContentAdded: async ({ payload }) => {
+        await onContestActivity(payload);
+      },
+      ContestContentUpvoted: async ({ payload }) => {
+        await onContestActivity(payload);
+      },
+    },
+  };
+}
+
+const upgradeUserTier = async (userId: number, toTier: number) => {
+  await models.User.update(
+    { tier: toTier },
+    {
+      where: {
+        id: userId,
+        tier: {
+          [Op.lt]: toTier,
+        },
+      },
+    },
+  );
+};
+
+type ContestActivityPayload = {
+  contest_address: string;
+  contest_id?: number;
+};
+
+const onContestActivity = async ({
+  contest_address,
+  contest_id,
+}: ContestActivityPayload) => {
+  const contestManager = await findActiveContestManager(contest_address, {
+    include: [
+      {
+        model: models.Community,
+        required: true,
+        include: [
+          {
+            model: models.ChainNode,
+            required: true,
+          },
+        ],
+      },
+    ],
+  });
+  if (!contestManager?.Community?.ChainNode) {
+    log.warn(
+      `Active contest manager with chain node not found for contest address ${contest_address}`,
+    );
+    return;
+  }
+
+  const contestCreatorAddress = await models.Address.findOne({
+    where: {
+      address: contestManager.creator_address!,
+    },
+    include: [
+      {
+        model: models.User,
+        required: true,
+      },
+    ],
+  });
+  if (!contestCreatorAddress?.User) {
+    log.warn(
+      `Contest creator user not found for address ${contestManager.creator_address!}`,
+    );
+    return;
+  }
+
+  if (contestCreatorAddress.User.tier >= 4) return;
+
+  const rpc = getChainNodeUrl({
+    url: contestManager!.Community!.ChainNode!.url,
+    private_url: contestManager!.Community!.ChainNode!.private_url,
+  });
+
+  const chain: commonProtocol.EvmProtocolChain = {
+    eth_chain_id: contestManager.Community!.ChainNode!.eth_chain_id!,
+    rpc,
+  };
+
+  const { contestBalance } = await commonProtocol.getContestScore(
+    chain,
+    contest_address,
+    contestManager.prize_percentage!,
+    contestManager.payout_structure,
+    contest_id,
+  );
+
+  const contestBalanceInt = BigInt(contestBalance ?? '0');
+
+  if (!contestBalanceInt) {
+    return;
+  }
+
+  // must be USDC or launchpad token
+
+  const isUSDC = [
+    USDC_BASE_MAINNET_ADDRESS,
+    USDC_BASE_SEPOLIA_ADDRESS,
+  ].includes(contestManager.funding_token_address!);
+
+  const isLaunchpadToken = !!(await models.LaunchpadToken.findOne({
+    where: {
+      // launchpad token addresses are all lowercase
+      token_address: contestManager.funding_token_address!.toLowerCase(),
+    },
+  }));
+
+  if (
+    (isUSDC && contestBalanceInt >= UPGRADE_MIN_USDC_BALANCE) ||
+    (isLaunchpadToken && contestBalanceInt >= UPGRADE_MIN_LAUNCHPAD_BALANCE)
+  ) {
+    // contest has been funded, upgrade contest creator to tier 4
+    await upgradeUserTier(contestCreatorAddress.User.id!, 4);
path: libs/model/src/aggregates/user/UpgradeTier.policy.ts
user: login: timolegros
body: ^
created_at: 2025-04-10T12:24:38Z
author_association: COLLABORATOR
diff_hunk: @@ -118,6 +118,11 @@ export function ContestWorker(): Policy<typeof inputs> {
           );
         }
 
+        console.log(
path: libs/model/src/policies/ContestWorker.policy.ts
user: login: timolegros
body: If we want to keep this use `log.trace`
created_at: 2025-04-10T12:25:25Z
author_association: COLLABORATOR
diff_hunk: @@ -291,6 +292,37 @@ const xpChainEventCreatedMapper: EvmMapper<'XpChainEventCreated'> = (
   };
 };
 
+const transferSingleMapper: EvmMapper<'NamespaceTransferSingle'> = (
+  event: EvmEvent,
+) => {
+  const decoded = decodeLog({
+    abi: erc1155Abi,
+    eventName: 'TransferSingle',
+    data: event.rawLog.data,
+    topics: event.rawLog.topics,
+  });
+
+  const args = decoded.args as Record<string, unknown>;
path: libs/model/src/services/evmChainEvents/chain-event-utils.ts
user: login: timolegros
body: The point of Viem is to strictly type decoded events like this so type coercing `decoded.args` to an arbitrary record is incorrect.

Why did you do this? Type seems to work fine for me:

![image](https://github.com/user-attachments/assets/8d69f8e0-0f87-4433-8412-6935ac374589).

You shouldn't have to do all the type coercions you do below either.
created_at: 2025-04-10T12:31:46Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,215 @@
+import { config, dispose } from '@hicommonwealth/core';
+import { commonProtocol } from '@hicommonwealth/evm-protocols';
+import { emitEvent, tokenBalanceCache } from '@hicommonwealth/model';
+import { Community, EventPair, User } from '@hicommonwealth/schemas';
+import { ZERO_ADDRESS } from '@hicommonwealth/shared';
+import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest';
+import { z } from 'zod';
+import {
+  UPGRADE_MIN_USDC_BALANCE,
+  UpgradeTierPolicy,
+} from '../../src/aggregates/user/UpgradeTier.policy';
+import { models } from '../../src/database';
+import { USDC_BASE_MAINNET_ADDRESS } from '../../src/services/openai/parseBotCommand';
+import { seed } from '../../src/tester';
+import { drainOutbox } from '../utils';
+
+const buildNamespaceTransferSingleEvent = (
+  contractAddress: string,
+  from: string,
+  to: string,
+  id: string,
+  value: string,
+) => ({
+  event_name: 'NamespaceTransferSingle',
+  event_payload: {
+    eventSource: {
+      ethChainId: 1,
+    },
+    rawLog: {
+      address: contractAddress,
+      topics: ['0xabcdef', '0x123456'],
+      data: '0xdata',
+      blockNumber: '123456' as unknown as bigint,
path: libs/model/test/user/upgrade-tier-lifecycle.spec.ts
user: login: timolegros
body: Why not just use a bigint?
created_at: 2025-04-10T12:36:50Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,215 @@
+import { config, dispose } from '@hicommonwealth/core';
+import { commonProtocol } from '@hicommonwealth/evm-protocols';
+import { emitEvent, tokenBalanceCache } from '@hicommonwealth/model';
+import { Community, EventPair, User } from '@hicommonwealth/schemas';
+import { ZERO_ADDRESS } from '@hicommonwealth/shared';
+import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest';
+import { z } from 'zod';
+import {
+  UPGRADE_MIN_USDC_BALANCE,
+  UpgradeTierPolicy,
+} from '../../src/aggregates/user/UpgradeTier.policy';
+import { models } from '../../src/database';
+import { USDC_BASE_MAINNET_ADDRESS } from '../../src/services/openai/parseBotCommand';
+import { seed } from '../../src/tester';
+import { drainOutbox } from '../utils';
+
+const buildNamespaceTransferSingleEvent = (
+  contractAddress: string,
+  from: string,
+  to: string,
+  id: string,
+  value: string,
+) => ({
+  event_name: 'NamespaceTransferSingle',
+  event_payload: {
+    eventSource: {
+      ethChainId: 1,
+    },
+    rawLog: {
+      address: contractAddress,
+      topics: ['0xabcdef', '0x123456'],
+      data: '0xdata',
+      blockNumber: '123456' as unknown as bigint,
+      blockHash: '0xblockhash',
+      transactionIndex: 0,
+      removed: false,
+      transactionHash: '0xtxhash',
+      logIndex: 0,
+    },
+    block: {
+      number: '123456' as unknown as bigint,
+      timestamp: '1678901234' as unknown as bigint,
+      hash: '0xblockhash',
+      logsBloom: '0xlogsbloom',
+      parentHash: '0xparenthash',
+      miner: '0xminer',
+      gasLimit: '10000000' as unknown as bigint,
+    },
+    parsedArgs: {
+      operator: '0x1234567890123456789012345678901234567890' as `0x${string}`,
+      from: from as `0x${string}`,
+      to: to as `0x${string}`,
+      id: id as unknown as bigint,
+      value: value as unknown as bigint,
+    },
+  },
+});
+
+describe('Upgrade Tiers lifecycle', () => {
+  const userAddress: string = '0x8888888888888888888888888888888888888888';
+  const contestAddress: string = '0x1234567890123456789012345678901234567890';
+  const contestId: number = 1;
+
+  let community: z.infer<typeof Community>;
+  let user: z.infer<typeof User>;
+
+  beforeAll(async () => {
+    const [node1] = await seed('ChainNode', {
+      id: 1,
+      eth_chain_id: 1,
+    });
+    const [user1] = await seed('User', {
+      tier: 0,
path: libs/model/test/user/upgrade-tier-lifecycle.spec.ts
user: login: timolegros
body: UserTierMap
created_at: 2025-04-10T12:37:11Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,215 @@
+import { config, dispose } from '@hicommonwealth/core';
+import { commonProtocol } from '@hicommonwealth/evm-protocols';
+import { emitEvent, tokenBalanceCache } from '@hicommonwealth/model';
+import { Community, EventPair, User } from '@hicommonwealth/schemas';
+import { ZERO_ADDRESS } from '@hicommonwealth/shared';
+import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest';
+import { z } from 'zod';
+import {
+  UPGRADE_MIN_USDC_BALANCE,
+  UpgradeTierPolicy,
+} from '../../src/aggregates/user/UpgradeTier.policy';
+import { models } from '../../src/database';
+import { USDC_BASE_MAINNET_ADDRESS } from '../../src/services/openai/parseBotCommand';
+import { seed } from '../../src/tester';
+import { drainOutbox } from '../utils';
+
+const buildNamespaceTransferSingleEvent = (
+  contractAddress: string,
+  from: string,
+  to: string,
+  id: string,
+  value: string,
+) => ({
+  event_name: 'NamespaceTransferSingle',
+  event_payload: {
+    eventSource: {
+      ethChainId: 1,
+    },
+    rawLog: {
+      address: contractAddress,
+      topics: ['0xabcdef', '0x123456'],
+      data: '0xdata',
+      blockNumber: '123456' as unknown as bigint,
+      blockHash: '0xblockhash',
+      transactionIndex: 0,
+      removed: false,
+      transactionHash: '0xtxhash',
+      logIndex: 0,
+    },
+    block: {
+      number: '123456' as unknown as bigint,
+      timestamp: '1678901234' as unknown as bigint,
+      hash: '0xblockhash',
+      logsBloom: '0xlogsbloom',
+      parentHash: '0xparenthash',
+      miner: '0xminer',
+      gasLimit: '10000000' as unknown as bigint,
+    },
+    parsedArgs: {
+      operator: '0x1234567890123456789012345678901234567890' as `0x${string}`,
+      from: from as `0x${string}`,
+      to: to as `0x${string}`,
+      id: id as unknown as bigint,
+      value: value as unknown as bigint,
+    },
+  },
+});
+
+describe('Upgrade Tiers lifecycle', () => {
+  const userAddress: string = '0x8888888888888888888888888888888888888888';
+  const contestAddress: string = '0x1234567890123456789012345678901234567890';
+  const contestId: number = 1;
+
+  let community: z.infer<typeof Community>;
+  let user: z.infer<typeof User>;
+
+  beforeAll(async () => {
+    const [node1] = await seed('ChainNode', {
+      id: 1,
+      eth_chain_id: 1,
+    });
+    const [user1] = await seed('User', {
+      tier: 0,
+    });
+    const [community1] = await seed('Community', {
+      chain_node_id: node1!.id,
+      profile_count: 1,
+      namespace_address: '0xFFF1234567890123456789012345678901234567',
+      Addresses: [
+        {
+          role: 'member',
+          address: userAddress,
+          user_id: user1!.id,
+          verified: new Date(),
+          last_active: new Date().toISOString(),
+        },
+      ],
+    });
+
+    const [topic1] = await seed('Topic', {
+      id: 1,
+      community_id: community1!.id,
+    });
+
+    await models.ContestManager.create({
+      name: 'hello',
+      community_id: community1!.id,
+      payout_structure: [],
+      ticker: 'ABC',
+      decimals: 0,
+      contest_address: contestAddress,
+      cancelled: false,
+      ended: false,
+      is_farcaster_contest: false,
+      topic_id: topic1!.id,
+      interval: 0,
+      funding_token_address: USDC_BASE_MAINNET_ADDRESS,
+      contests: [
+        {
+          contest_address: contestAddress,
+          contest_id: contestId,
+          start_time: new Date(),
+          end_time: new Date(new Date().getTime() + 60 * 60 * 1000),
+          score: [],
+        },
+      ],
+      environment: config.APP_ENV,
+      creator_address: userAddress,
+      created_at: new Date(),
+    });
+
+    user = user1!;
+    community = community1!;
+
+    vi.spyOn(tokenBalanceCache, 'getBalances').mockResolvedValue({
+      [userAddress]: '5',
+    });
+  });
+
+  afterAll(async () => {
+    await dispose()();
+    vi.restoreAllMocks();
+  });
+
+  describe('Nomination Upgrade Policy', async () => {
+    test('should upgrade user to tier 4 when 5 or more nomination tokens are held', async () => {
+      const userBefore = await models.User.findByPk(user.id);
+      expect(userBefore?.tier).toBe(0);
+
+      await emitEvent(models.Outbox, [
+        buildNamespaceTransferSingleEvent(
+          community.namespace_address!,
+          ZERO_ADDRESS,
+          userAddress,
+          '3',
+          '5',
+        ) as EventPair<'NamespaceTransferSingle'>,
+      ]);
+
+      await drainOutbox(['NamespaceTransferSingle'], UpgradeTierPolicy);
+
+      const userAfter = await models.User.findByPk(user.id);
+      expect(userAfter?.tier).toBe(4);
+    });
+  });
+
+  describe('Contest Upgrade Policy', async () => {
+    test('should upgrade user to tier 4 when contest is funded and there is contest activity', async () => {
+      await models.User.update({ tier: 0 }, { where: { id: user.id } });
+
+      const userBefore = await models.User.findByPk(user.id);
+      expect(userBefore?.tier).toBe(0);
+
+      vi.spyOn(commonProtocol, 'getContestScore').mockResolvedValue({
+        contestBalance: (UPGRADE_MIN_USDC_BALANCE - 1n).toString(),
+        scores: [],
+      });
+
+      await emitEvent(models.Outbox, [
+        {
+          event_name: 'ContestContentAdded',
+          event_payload: {
+            contest_address: contestAddress,
+            creator_address: userAddress,
+            content_id: 1,
+            content_url: 'thread-id-1',
+          },
+        },
+      ]);
+
+      await drainOutbox(['ContestContentAdded'], UpgradeTierPolicy);
+
+      const userAfter = await models.User.findByPk(user.id);
+      expect(
+        userAfter!.tier,
+        'user tier should not change since contest balance is insufficient',
+      ).toBe(0);
+
+      vi.spyOn(commonProtocol, 'getContestScore').mockResolvedValue({
+        contestBalance: UPGRADE_MIN_USDC_BALANCE.toString(),
+        scores: [],
+      });
+
+      await emitEvent(models.Outbox, [
+        {
+          event_name: 'ContestContentUpvoted',
+          event_payload: {
+            contest_address: contestAddress,
+            content_id: 1,
+            voting_power: '1',
+            voter_address: userAddress,
+          },
+        },
+      ]);
+
+      await drainOutbox(['ContestContentUpvoted'], UpgradeTierPolicy);
+
+      const userAfter2 = await models.User.findByPk(user.id);
+      expect(
+        userAfter2!.tier,
+        'balance is sufficient, user should be upgraded to tier 4',
+      ).toBe(4);
path: libs/model/test/user/upgrade-tier-lifecycle.spec.ts
user: login: timolegros
body: UserTierMap
created_at: 2025-04-10T12:37:50Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,215 @@
+import { config, dispose } from '@hicommonwealth/core';
+import { commonProtocol } from '@hicommonwealth/evm-protocols';
+import { emitEvent, tokenBalanceCache } from '@hicommonwealth/model';
+import { Community, EventPair, User } from '@hicommonwealth/schemas';
+import { ZERO_ADDRESS } from '@hicommonwealth/shared';
+import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest';
+import { z } from 'zod';
+import {
+  UPGRADE_MIN_USDC_BALANCE,
+  UpgradeTierPolicy,
+} from '../../src/aggregates/user/UpgradeTier.policy';
+import { models } from '../../src/database';
+import { USDC_BASE_MAINNET_ADDRESS } from '../../src/services/openai/parseBotCommand';
+import { seed } from '../../src/tester';
+import { drainOutbox } from '../utils';
+
+const buildNamespaceTransferSingleEvent = (
+  contractAddress: string,
+  from: string,
+  to: string,
+  id: string,
+  value: string,
+) => ({
+  event_name: 'NamespaceTransferSingle',
+  event_payload: {
+    eventSource: {
+      ethChainId: 1,
+    },
+    rawLog: {
+      address: contractAddress,
+      topics: ['0xabcdef', '0x123456'],
+      data: '0xdata',
+      blockNumber: '123456' as unknown as bigint,
+      blockHash: '0xblockhash',
+      transactionIndex: 0,
+      removed: false,
+      transactionHash: '0xtxhash',
+      logIndex: 0,
+    },
+    block: {
+      number: '123456' as unknown as bigint,
+      timestamp: '1678901234' as unknown as bigint,
+      hash: '0xblockhash',
+      logsBloom: '0xlogsbloom',
+      parentHash: '0xparenthash',
+      miner: '0xminer',
+      gasLimit: '10000000' as unknown as bigint,
+    },
+    parsedArgs: {
+      operator: '0x1234567890123456789012345678901234567890' as `0x${string}`,
+      from: from as `0x${string}`,
+      to: to as `0x${string}`,
+      id: id as unknown as bigint,
+      value: value as unknown as bigint,
+    },
+  },
+});
+
+describe('Upgrade Tiers lifecycle', () => {
+  const userAddress: string = '0x8888888888888888888888888888888888888888';
+  const contestAddress: string = '0x1234567890123456789012345678901234567890';
+  const contestId: number = 1;
+
+  let community: z.infer<typeof Community>;
+  let user: z.infer<typeof User>;
+
+  beforeAll(async () => {
+    const [node1] = await seed('ChainNode', {
+      id: 1,
+      eth_chain_id: 1,
+    });
+    const [user1] = await seed('User', {
+      tier: 0,
+    });
+    const [community1] = await seed('Community', {
+      chain_node_id: node1!.id,
+      profile_count: 1,
+      namespace_address: '0xFFF1234567890123456789012345678901234567',
+      Addresses: [
+        {
+          role: 'member',
+          address: userAddress,
+          user_id: user1!.id,
+          verified: new Date(),
+          last_active: new Date().toISOString(),
+        },
+      ],
+    });
+
+    const [topic1] = await seed('Topic', {
+      id: 1,
+      community_id: community1!.id,
+    });
+
+    await models.ContestManager.create({
+      name: 'hello',
+      community_id: community1!.id,
+      payout_structure: [],
+      ticker: 'ABC',
+      decimals: 0,
+      contest_address: contestAddress,
+      cancelled: false,
+      ended: false,
+      is_farcaster_contest: false,
+      topic_id: topic1!.id,
+      interval: 0,
+      funding_token_address: USDC_BASE_MAINNET_ADDRESS,
+      contests: [
+        {
+          contest_address: contestAddress,
+          contest_id: contestId,
+          start_time: new Date(),
+          end_time: new Date(new Date().getTime() + 60 * 60 * 1000),
+          score: [],
+        },
+      ],
+      environment: config.APP_ENV,
+      creator_address: userAddress,
+      created_at: new Date(),
+    });
+
+    user = user1!;
+    community = community1!;
+
+    vi.spyOn(tokenBalanceCache, 'getBalances').mockResolvedValue({
+      [userAddress]: '5',
+    });
+  });
+
+  afterAll(async () => {
+    await dispose()();
+    vi.restoreAllMocks();
+  });
+
+  describe('Nomination Upgrade Policy', async () => {
+    test('should upgrade user to tier 4 when 5 or more nomination tokens are held', async () => {
+      const userBefore = await models.User.findByPk(user.id);
+      expect(userBefore?.tier).toBe(0);
+
+      await emitEvent(models.Outbox, [
+        buildNamespaceTransferSingleEvent(
+          community.namespace_address!,
+          ZERO_ADDRESS,
+          userAddress,
+          '3',
+          '5',
+        ) as EventPair<'NamespaceTransferSingle'>,
+      ]);
+
+      await drainOutbox(['NamespaceTransferSingle'], UpgradeTierPolicy);
+
+      const userAfter = await models.User.findByPk(user.id);
+      expect(userAfter?.tier).toBe(4);
+    });
+  });
+
+  describe('Contest Upgrade Policy', async () => {
+    test('should upgrade user to tier 4 when contest is funded and there is contest activity', async () => {
+      await models.User.update({ tier: 0 }, { where: { id: user.id } });
+
+      const userBefore = await models.User.findByPk(user.id);
+      expect(userBefore?.tier).toBe(0);
+
+      vi.spyOn(commonProtocol, 'getContestScore').mockResolvedValue({
+        contestBalance: (UPGRADE_MIN_USDC_BALANCE - 1n).toString(),
+        scores: [],
+      });
+
+      await emitEvent(models.Outbox, [
+        {
+          event_name: 'ContestContentAdded',
+          event_payload: {
+            contest_address: contestAddress,
+            creator_address: userAddress,
+            content_id: 1,
+            content_url: 'thread-id-1',
+          },
+        },
+      ]);
+
+      await drainOutbox(['ContestContentAdded'], UpgradeTierPolicy);
+
+      const userAfter = await models.User.findByPk(user.id);
+      expect(
+        userAfter!.tier,
+        'user tier should not change since contest balance is insufficient',
+      ).toBe(0);
path: libs/model/test/user/upgrade-tier-lifecycle.spec.ts
user: login: timolegros
body: UserTierMap
created_at: 2025-04-10T12:37:59Z
author_association: COLLABORATOR
diff_hunk: @@ -387,6 +387,16 @@ export const events = {
     }),
   }),
 
+  NamespaceTransferSingle: ChainEventBase.extend({
path: libs/schemas/src/events/events.schemas.ts
user: login: timolegros
body: It doesn't seem like you use a majority of the data provided as part of the `ChainEventBase` event so maybe it's best to not extend ChainEventBase and just pass the parameters  you need.
created_at: 2025-04-10T12:39:03Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,215 @@
+import { config, dispose } from '@hicommonwealth/core';
+import { commonProtocol } from '@hicommonwealth/evm-protocols';
+import { emitEvent, tokenBalanceCache } from '@hicommonwealth/model';
+import { Community, EventPair, User } from '@hicommonwealth/schemas';
+import { ZERO_ADDRESS } from '@hicommonwealth/shared';
+import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest';
+import { z } from 'zod';
+import {
+  UPGRADE_MIN_USDC_BALANCE,
+  UpgradeTierPolicy,
+} from '../../src/aggregates/user/UpgradeTier.policy';
+import { models } from '../../src/database';
+import { USDC_BASE_MAINNET_ADDRESS } from '../../src/services/openai/parseBotCommand';
+import { seed } from '../../src/tester';
+import { drainOutbox } from '../utils';
+
+const buildNamespaceTransferSingleEvent = (
+  contractAddress: string,
+  from: string,
+  to: string,
+  id: string,
+  value: string,
+) => ({
+  event_name: 'NamespaceTransferSingle',
+  event_payload: {
+    eventSource: {
+      ethChainId: 1,
+    },
+    rawLog: {
+      address: contractAddress,
+      topics: ['0xabcdef', '0x123456'],
+      data: '0xdata',
+      blockNumber: '123456' as unknown as bigint,
path: libs/model/test/user/upgrade-tier-lifecycle.spec.ts
user: login: rbentcw
body: Apparently we can't pass big ints to the outbox directly because sequelize doesn't know how to serialize it: `TypeError: Do not know how to serialize a BigInt`
created_at: 2025-04-11T18:59:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,215 @@
+import { config, dispose } from '@hicommonwealth/core';
+import { commonProtocol } from '@hicommonwealth/evm-protocols';
+import { emitEvent, tokenBalanceCache } from '@hicommonwealth/model';
+import { Community, EventPair, User } from '@hicommonwealth/schemas';
+import { ZERO_ADDRESS } from '@hicommonwealth/shared';
+import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest';
+import { z } from 'zod';
+import {
+  UPGRADE_MIN_USDC_BALANCE,
+  UpgradeTierPolicy,
+} from '../../src/aggregates/user/UpgradeTier.policy';
+import { models } from '../../src/database';
+import { USDC_BASE_MAINNET_ADDRESS } from '../../src/services/openai/parseBotCommand';
+import { seed } from '../../src/tester';
+import { drainOutbox } from '../utils';
+
+const buildNamespaceTransferSingleEvent = (
+  contractAddress: string,
+  from: string,
+  to: string,
+  id: string,
+  value: string,
+) => ({
+  event_name: 'NamespaceTransferSingle',
+  event_payload: {
+    eventSource: {
+      ethChainId: 1,
+    },
+    rawLog: {
+      address: contractAddress,
+      topics: ['0xabcdef', '0x123456'],
+      data: '0xdata',
+      blockNumber: '123456' as unknown as bigint,
path: libs/model/test/user/upgrade-tier-lifecycle.spec.ts
user: login: timolegros
body: Ah right - I recommend using the `serializeBigIntObj` function like in `nodeProcessing.ts` if you want to avoid these type coercions. Would also more accurately reflect "real" chain-event insertion in the Outbox.

Nbd tho.
created_at: 2025-04-11T21:16:39Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,215 @@
+import { config, dispose } from '@hicommonwealth/core';
+import { commonProtocol } from '@hicommonwealth/evm-protocols';
+import { emitEvent, tokenBalanceCache } from '@hicommonwealth/model';
+import { Community, EventPair, User } from '@hicommonwealth/schemas';
+import { ZERO_ADDRESS } from '@hicommonwealth/shared';
+import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest';
+import { z } from 'zod';
+import {
+  UPGRADE_MIN_USDC_BALANCE,
+  UpgradeTierPolicy,
+} from '../../src/aggregates/user/UpgradeTier.policy';
+import { models } from '../../src/database';
+import { USDC_BASE_MAINNET_ADDRESS } from '../../src/services/openai/parseBotCommand';
+import { seed } from '../../src/tester';
+import { drainOutbox } from '../utils';
+
+const buildNamespaceTransferSingleEvent = (
+  contractAddress: string,
+  from: string,
+  to: string,
+  id: string,
+  value: string,
+) => ({
+  event_name: 'NamespaceTransferSingle',
+  event_payload: {
+    eventSource: {
+      ethChainId: 1,
+    },
+    rawLog: {
+      address: contractAddress,
+      topics: ['0xabcdef', '0x123456'],
+      data: '0xdata',
+      blockNumber: '123456' as unknown as bigint,
path: libs/model/test/user/upgrade-tier-lifecycle.spec.ts
user: login: rbentcw
body: I ended up commenting out the event source logic because it's not the ideal solution. I'll resolve in my other PR that wires up the nomination events.
created_at: 2025-04-17T21:30:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,16 @@
+'use strict';
+
+/** @type {import('sequelize-cli').Migration} */
+module.exports = {
+  async up(queryInterface, Sequelize) {
+    await queryInterface.addColumn('Communities', 'namespace_verified', {
path: packages/commonwealth/server/migrations/20250411211930-add-community-namespace-verified.js
user: login: timolegros
body: Combine with above? Both require a full table lock anyways so might as well just grab it once.
created_at: 2025-04-17T22:06:34Z
author_association: COLLABORATOR

PR comments:
user: login: dillchen
created_at: 2025-04-04T20:10:28Z
body: ### Update Tier 4 Config
- Trade Amount:
  - 0.25 ETH is equivalent to 250,000,000,000,000,000 wei (2.5 × 10¹⁷ wei).
  - Ticket for Contests Funding
            
- For Contest Funding: Our Launchpad Tokens or like “Approved Token list” USDC
  - If Launchpad Token => 
  - Prize Amount: 1000 full tokens (launchpad tokens) at least
  - If USDC -> $50 
author_association: CONTRIBUTOR
user: login: dillchen
created_at: 2025-04-18T18:31:48Z
body: woot
author_association: CONTRIBUTOR