From 7212366e4c945c6356aa488eb6fd6b149521346f Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Wed, 20 Nov 2024 15:04:54 -0800 Subject: [PATCH 01/17] fix contest worker lifecycle test --- .../PerformContestRollovers.command.ts | 4 +- .../contest-worker-policy-lifecycle.spec.ts | 232 ++++++++++++++++++ .../contest-worker-policy.spec.ts | 179 -------------- 3 files changed, 234 insertions(+), 181 deletions(-) create mode 100644 libs/model/test/contest-worker/contest-worker-policy-lifecycle.spec.ts delete mode 100644 libs/model/test/contest-worker/contest-worker-policy.spec.ts diff --git a/libs/model/src/contest/PerformContestRollovers.command.ts b/libs/model/src/contest/PerformContestRollovers.command.ts index 7bb66979ea3..59e0b8b51e0 100644 --- a/libs/model/src/contest/PerformContestRollovers.command.ts +++ b/libs/model/src/contest/PerformContestRollovers.command.ts @@ -40,7 +40,7 @@ export function PerformContestRollovers(): Command< GROUP BY contest_address)) co ON co.contest_address = cm.contest_address AND ( - cm.interval = 0 AND cm.ended IS NOT TRUE + (cm.interval = 0 AND cm.ended IS NOT TRUE) OR cm.interval > 0 ) @@ -88,7 +88,7 @@ export function PerformContestRollovers(): Command< ); // clean up neynar webhooks when farcaster contest ends - if (interval === 0 && neynar_webhook_id) { + if (neynar_webhook_id) { try { const client = new NeynarAPIClient( config.CONTESTS.NEYNAR_API_KEY!, diff --git a/libs/model/test/contest-worker/contest-worker-policy-lifecycle.spec.ts b/libs/model/test/contest-worker/contest-worker-policy-lifecycle.spec.ts new file mode 100644 index 00000000000..9b696676f27 --- /dev/null +++ b/libs/model/test/contest-worker/contest-worker-policy-lifecycle.spec.ts @@ -0,0 +1,232 @@ +import { expect } from 'chai'; +import Sinon from 'sinon'; + +import { Actor, command, dispose, EventNames } from '@hicommonwealth/core'; +import { literal } from 'sequelize'; +import { afterAll, beforeAll, describe, test } from 'vitest'; +import { commonProtocol, Contest, emitEvent, models } from '../../src'; +import { Contests } from '../../src/contest'; +import { ContestWorker } from '../../src/policies'; +import { bootstrap_testing, seed } from '../../src/tester'; +import { drainOutbox } from '../utils/outbox-drain'; + +describe('Contest Worker Policy Lifecycle', () => { + const addressId = 444; + const address = '0x0'; + const communityId = 'ethhh'; + const threadId = 888; + const threadTitle = 'Hello There'; + const contestAddress = '0x1'; + const contestId = 0; + const contentId = 1; + let topicId: number = 0; + + beforeAll(async () => { + await bootstrap_testing(import.meta); + + const [chainNode] = await seed('ChainNode', { contracts: [] }); + const [user] = await seed( + 'User', + { + isAdmin: false, + selected_community_id: undefined, + }, + //{ mock: true, log: true }, + ); + const [community] = await seed('Community', { + id: communityId, + chain_node_id: chainNode!.id, + lifetime_thread_count: 0, + profile_count: 1, + Addresses: [ + { + id: addressId, + user_id: user!.id, + address, + role: 'member', + }, + ], + topics: [ + { + id: topicId, + name: 'hello', + community_id: communityId, + group_ids: [], + }, + ], + contest_managers: [ + { + contest_address: contestAddress, + cancelled: false, + ended: false, + is_farcaster_contest: false, + topic_id: topicId, + interval: 0, + contests: [ + { + contest_address: contestAddress, + contest_id: contestId, + start_time: new Date(), + end_time: new Date(new Date().getTime() + 60 * 60 * 1000), + score: [], + }, + ], + }, + ], + }); + expect(community!.contest_managers!.length).to.eq(1); + expect(community!.contest_managers![0].contests!.length).to.eq(1); + await seed('Thread', { + id: threadId, + community_id: communityId, + address_id: addressId, + topic_id: topicId, + deleted_at: undefined, + pinned: false, + read_only: false, + reaction_weights_sum: '0', + }); + }); + + afterAll(async () => { + Sinon.restore(); + await dispose()(); + }); + + test('Handle ThreadCreated, ThreadUpvoted and Rollover', async () => { + const addContentStub = Sinon.stub( + commonProtocol.contestHelper, + 'addContentBatch', + ).resolves([]); + + await emitEvent(models.Outbox, [ + { + event_name: EventNames.ThreadCreated, + event_payload: { + id: threadId, + community_id: communityId, + address_id: addressId, + title: threadTitle, + created_by: address, + canvas_signed_data: '', + canvas_msg_id: '', + kind: '', + stage: '', + body: '', + view_count: 0, + reaction_count: 0, + reaction_weights_sum: '0', + comment_count: 0, + deleted_at: undefined, + pinned: false, + read_only: false, + topic_id: topicId, + contestManagers: [ + { + contest_address: contestAddress, + }, + ], + }, + }, + ]); + + await drainOutbox(['ThreadCreated'], ContestWorker); + + expect(addContentStub.called, 'addContent was not called').to.be.true; + + const voteContentStub = Sinon.stub( + commonProtocol.contestHelper, + 'voteContentBatch', + ).resolves([]); + + await emitEvent(models.Outbox, [ + { + event_name: EventNames.ContestContentAdded, + event_payload: { + content_id: 0, + content_url: '/ethhh/discussion/888', + contest_address: contestAddress, + creator_address: address, + }, + }, + ]); + + await drainOutbox(['ContestContentAdded'], Contests); + + const contentProjection = await models.ContestAction.findOne({ + where: { + action: 'added', + contest_address: contestAddress, + contest_id: contestId, + content_url: '/ethhh/discussion/888', + }, + }); + + expect(contentProjection).to.exist; + + await emitEvent(models.Outbox, [ + { + event_name: EventNames.ThreadUpvoted, + event_payload: { + community_id: communityId, + address_id: addressId, + reaction: 'like', + thread_id: threadId, + topic_id: topicId, + contestManagers: [{ contest_address: contestAddress }], + }, + }, + ]); + + await drainOutbox(['ThreadUpvoted'], ContestWorker); + + expect(voteContentStub.called, 'voteContent was not called').to.be.true; + + command( + Contest.PerformContestRollovers(), + { + actor: {} as Actor, + payload: { id: '' }, + }, + false, + ); + + const contestManagerBeforeContestEnded = + await models.ContestManager.findByPk(contestAddress); + expect( + contestManagerBeforeContestEnded!.ended, + 'contest should not be rolled over yet', + ).to.be.false; + + // simulate contest has ended + await models.Contest.update( + { + start_time: literal(`NOW() - INTERVAL '10 seconds'`), + end_time: literal(`NOW() - INTERVAL '5 seconds'`), + }, + { + where: { + contest_address: contestAddress, + contest_id: contestId, + }, + }, + ); + + await command( + Contest.PerformContestRollovers(), + { + actor: {} as Actor, + payload: { id: '' }, + }, + false, + ); + + const contestManagerAfterContestEnded = + await models.ContestManager.findByPk(contestAddress); + + expect( + contestManagerAfterContestEnded!.ended, + 'contest should have rolled over', + ).to.be.true; + }); +}); diff --git a/libs/model/test/contest-worker/contest-worker-policy.spec.ts b/libs/model/test/contest-worker/contest-worker-policy.spec.ts deleted file mode 100644 index b0a0f210601..00000000000 --- a/libs/model/test/contest-worker/contest-worker-policy.spec.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { expect } from 'chai'; -import Sinon from 'sinon'; - -import { dispose, handleEvent } from '@hicommonwealth/core'; -import { afterAll, beforeAll, describe, test } from 'vitest'; -import { commonProtocol, models } from '../../src'; -import { ContestWorker } from '../../src/policies'; -import { seed } from '../../src/tester'; - -describe('Contest Worker Policy', () => { - const addressId = 444; - const address = '0x0'; - const communityId = 'ethhh'; - const threadId = 888; - const threadTitle = 'Hello There'; - const contestAddress = '0x1'; - let topicId: number = 0; - - beforeAll(async () => { - const [chainNode] = await seed('ChainNode', { contracts: [] }); - const [user] = await seed( - 'User', - { - isAdmin: false, - selected_community_id: undefined, - }, - //{ mock: true, log: true }, - ); - const [community] = await seed('Community', { - id: communityId, - chain_node_id: chainNode!.id, - lifetime_thread_count: 0, - profile_count: 1, - Addresses: [ - { - id: addressId, - user_id: user!.id, - address, - role: 'member', - }, - ], - contest_managers: [ - { - contest_address: contestAddress, - cancelled: false, - topics: [ - { - name: 'zzz', - }, - ], - }, - ], - }); - topicId = community!.contest_managers![0].topics![0].id!; - expect(topicId, 'seeded topic not assigned to contest manager').to.exist; - await seed('Thread', { - id: threadId, - community_id: communityId, - address_id: addressId, - topic_id: topicId, - deleted_at: undefined, - pinned: false, - read_only: false, - }); - }); - - afterAll(async () => { - Sinon.restore(); - await dispose()(); - }); - - // TODO: fix this test - test.skip('Policy should handle ThreadCreated and ThreadUpvoted events', async () => { - { - const addContentStub = Sinon.stub( - commonProtocol.contestHelper, - 'addContentBatch', - ).resolves([]); - - await handleEvent( - ContestWorker(), - { - name: 'ThreadCreated', - payload: { - id: threadId, - community_id: communityId, - address_id: addressId, - title: threadTitle, - created_by: address, - canvas_signed_data: '', - canvas_msg_id: '', - kind: '', - stage: '', - body: '', - view_count: 0, - reaction_count: 0, - reaction_weights_sum: '0', - comment_count: 0, - deleted_at: undefined, - pinned: false, - read_only: false, - topic_id: topicId, - }, - }, - true, - ); - - expect(addContentStub.called, 'addContent was not called').to.be.true; - const fnArgs = addContentStub.args[0]; - expect(fnArgs[1]).to.equal( - contestAddress, - 'addContent called with wrong contractAddress', - ); - expect(fnArgs[2]).to.equal( - [address], - 'addContent called with wrong userAddress', - ); - expect(fnArgs[3]).to.equal( - '/ethhh/discussion/888', - 'addContent called with wrong contentUrl', - ); - } - - { - const voteContentStub = Sinon.stub( - commonProtocol.contestHelper, - 'voteContentBatch', - ).resolves([]); - - const contestId = 2; - const contentId = 199; - - const contest = await models.Contest.create({ - contest_address: contestAddress, - contest_id: contestId, - start_time: new Date(), - end_time: new Date(), - score: [], - }); - - await models.ContestAction.create({ - contest_address: contestAddress, - contest_id: contest.contest_id, - content_id: contentId, - actor_address: address, - action: 'added', - content_url: '/ethhh/discussion/888', - thread_id: threadId, - thread_title: threadTitle, - voting_power: '10', - created_at: new Date(), - }); - - await handleEvent(ContestWorker(), { - name: 'ThreadUpvoted', - payload: { - community_id: communityId, - address_id: addressId, - reaction: 'like', - thread_id: threadId, - }, - }); - - const fnArgs = voteContentStub.args[0]; - expect(fnArgs[1]).to.equal( - contestAddress, - 'voteContent called with wrong contractAddress', - ); - expect(fnArgs[2]).to.equal( - [address], - 'voteContent called with wrong userAddress', - ); - // expect(fnArgs[3]).to.equal( - // contentId.toString(), - // 'voteContent called with wrong contentId', - // ); - } - }); -}); From b82122b28ed764251d9f5b82e0970ca64b2ad885 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Wed, 20 Nov 2024 15:39:28 -0800 Subject: [PATCH 02/17] add contest check command --- .../src/contest/CheckContests.command.ts | 70 +++++++++++++++++++ .../contest/GetActiveContestManagers.query.ts | 13 ++-- libs/schemas/src/commands/contest.schemas.ts | 5 ++ libs/schemas/src/queries/contests.schemas.ts | 5 +- 4 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 libs/model/src/contest/CheckContests.command.ts diff --git a/libs/model/src/contest/CheckContests.command.ts b/libs/model/src/contest/CheckContests.command.ts new file mode 100644 index 00000000000..48298603f33 --- /dev/null +++ b/libs/model/src/contest/CheckContests.command.ts @@ -0,0 +1,70 @@ +import { Actor, logger, query, type Command } from '@hicommonwealth/core'; +import * as schemas from '@hicommonwealth/schemas'; +import Web3 from 'web3'; +import { config } from '../config'; +import { createOnchainContestVote } from '../policies/contest-utils'; +import { GetActiveContestManagers } from './GetActiveContestManagers.query'; + +const log = logger(import.meta); + +const getPrivateWalletAddress = () => { + const web3 = new Web3(); + const privateKey = config.WEB3.PRIVATE_KEY; + const account = web3.eth.accounts.privateKeyToAccount(privateKey); + const publicAddress = account.address; + return publicAddress; +}; + +export function CheckContests(): Command { + return { + ...schemas.CheckContests, + auth: [], + body: async () => { + const activeContestManagers = await query(GetActiveContestManagers(), { + actor: {} as Actor, + payload: {}, + }); + // find active contests that have content with no upvotes and will end in one hour + const contestsWithoutVote = activeContestManagers!.filter( + (contestManager) => + contestManager.actions.some((action) => action.action === 'added') && + !contestManager.actions.some( + (action) => action.action === 'upvoted', + ) && + Date.now() - contestManager.end_time.getTime() < 1000 * 60 * 60, + ); + + const promises = contestsWithoutVote.map(async (contestManager) => { + // add onchain vote to the first content + const firstContent = contestManager.actions.find( + (action) => action.action === 'added', + ); + + await createOnchainContestVote({ + contestManagers: [ + { + url: contestManager.url, + contest_address: contestManager.contest_address, + content_id: firstContent!.content_id, + }, + ], + content_url: firstContent!.content_url!, + author_address: getPrivateWalletAddress(), + }); + }); + + const promiseResults = await Promise.allSettled(promises); + + const errors = promiseResults + .filter(({ status }) => status === 'rejected') + .map( + (result) => + (result as PromiseRejectedResult).reason || '', + ); + + if (errors.length > 0) { + log.warn(`CheckContests: failed with errors: ${errors.join(', ')}"`); + } + }, + }; +} diff --git a/libs/model/src/contest/GetActiveContestManagers.query.ts b/libs/model/src/contest/GetActiveContestManagers.query.ts index a2d6e9e3b42..6cf838f1091 100644 --- a/libs/model/src/contest/GetActiveContestManagers.query.ts +++ b/libs/model/src/contest/GetActiveContestManagers.query.ts @@ -20,6 +20,7 @@ export function GetActiveContestManagers(): Query< url: string; private_url: string; contest_address: string; + end_time: string; max_contest_id: number; actions: Array>; }>( @@ -29,6 +30,7 @@ export function GetActiveContestManagers(): Query< cn.url, cm.contest_address, co.max_contest_id, + co.end_time, COALESCE(JSON_AGG(ca) FILTER (WHERE ca IS NOT NULL), '[]'::json) as actions FROM "Communities" c JOIN "ChainNodes" cn ON c.chain_node_id = cn.id @@ -44,9 +46,11 @@ export function GetActiveContestManagers(): Query< ca.created_at > co.start_time AND ca.created_at < co.end_time ) - WHERE cm.topic_id = :topic_id - AND cm.community_id = :community_id - AND cm.cancelled IS NOT TRUE + WHERE + cm.cancelled IS NOT TRUE + AND cm.ended IS NOT TRUE + ${payload.topic_id ? 'AND cm.topic_id = :topic_id' : ''} + ${payload.community_id ? 'AND cm.community_id = :community_id' : ''} AND ( cm.interval = 0 AND NOW() < co.end_time OR @@ -57,7 +61,7 @@ export function GetActiveContestManagers(): Query< { type: QueryTypes.SELECT, replacements: { - topic_id: payload.topic_id!, + topic_id: payload.topic_id, community_id: payload.community_id, }, }, @@ -67,6 +71,7 @@ export function GetActiveContestManagers(): Query< eth_chain_id: r.eth_chain_id, url: getChainNodeUrl(r), contest_address: r.contest_address, + end_time: new Date(r.end_time), max_contest_id: r.max_contest_id, actions: r.actions, })); diff --git a/libs/schemas/src/commands/contest.schemas.ts b/libs/schemas/src/commands/contest.schemas.ts index 2f25a9b7596..1fc3ef4462f 100644 --- a/libs/schemas/src/commands/contest.schemas.ts +++ b/libs/schemas/src/commands/contest.schemas.ts @@ -83,6 +83,11 @@ export const PerformContestRollovers = { output: z.object({}), }; +export const CheckContests = { + input: z.object({ id: z.string() }), + output: z.object({}), +}; + export const FarcasterCast = z.object({ object: z.string(), hash: z.string(), diff --git a/libs/schemas/src/queries/contests.schemas.ts b/libs/schemas/src/queries/contests.schemas.ts index 49ab365057f..d53d89a73ec 100644 --- a/libs/schemas/src/queries/contests.schemas.ts +++ b/libs/schemas/src/queries/contests.schemas.ts @@ -40,8 +40,8 @@ export const GetContest = { export const GetActiveContestManagers = { input: z.object({ - community_id: z.string(), - topic_id: z.number(), + community_id: z.string().optional(), + topic_id: z.number().optional(), }), output: z.array( z.object({ @@ -49,6 +49,7 @@ export const GetActiveContestManagers = { url: z.string(), contest_address: z.string(), max_contest_id: z.number(), + end_time: z.coerce.date(), actions: z.array(ContestAction), }), ), From 1836b341e8bbb20a43b22ea210aac8f1ec466a30 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Thu, 21 Nov 2024 11:52:29 -0800 Subject: [PATCH 03/17] fix test for check contest --- .../src/contest/CheckContests.command.ts | 3 +- .../contest/GetActiveContestManagers.query.ts | 10 +- libs/model/src/contest/index.ts | 1 + .../model/test/contest/check-contests.spec.ts | 207 ++++++++++++++++++ .../contest-worker-policy-lifecycle.spec.ts | 2 - .../commonwealthConsumer.ts | 37 +++- 6 files changed, 244 insertions(+), 16 deletions(-) create mode 100644 libs/model/test/contest/check-contests.spec.ts rename libs/model/test/{contest-worker => contest}/contest-worker-policy-lifecycle.spec.ts (97%) diff --git a/libs/model/src/contest/CheckContests.command.ts b/libs/model/src/contest/CheckContests.command.ts index 48298603f33..514813f1f7c 100644 --- a/libs/model/src/contest/CheckContests.command.ts +++ b/libs/model/src/contest/CheckContests.command.ts @@ -1,5 +1,6 @@ import { Actor, logger, query, type Command } from '@hicommonwealth/core'; import * as schemas from '@hicommonwealth/schemas'; +import moment from 'moment'; import Web3 from 'web3'; import { config } from '../config'; import { createOnchainContestVote } from '../policies/contest-utils'; @@ -31,7 +32,7 @@ export function CheckContests(): Command { !contestManager.actions.some( (action) => action.action === 'upvoted', ) && - Date.now() - contestManager.end_time.getTime() < 1000 * 60 * 60, + moment(contestManager.end_time).diff(moment(), 'minutes') < 60, ); const promises = contestsWithoutVote.map(async (contestManager) => { diff --git a/libs/model/src/contest/GetActiveContestManagers.query.ts b/libs/model/src/contest/GetActiveContestManagers.query.ts index 6cf838f1091..64be3e3e301 100644 --- a/libs/model/src/contest/GetActiveContestManagers.query.ts +++ b/libs/model/src/contest/GetActiveContestManagers.query.ts @@ -42,10 +42,10 @@ export function GetActiveContestManagers(): Query< FROM "Contests" GROUP BY contest_address) co ON cm.contest_address = co.contest_address LEFT JOIN "ContestActions" ca on ( - ca.contest_address = cm.contest_address AND - ca.created_at > co.start_time AND - ca.created_at < co.end_time - ) + ca.contest_address = cm.contest_address AND + ca.created_at > co.start_time AND + ca.created_at < co.end_time + ) WHERE cm.cancelled IS NOT TRUE AND cm.ended IS NOT TRUE @@ -56,7 +56,7 @@ export function GetActiveContestManagers(): Query< OR cm.interval > 0 ) - GROUP BY cn.eth_chain_id, cn.private_url, cn.url, cm.contest_address, co.max_contest_id + GROUP BY cn.eth_chain_id, cn.private_url, cn.url, cm.contest_address, co.max_contest_id, co.end_time `, { type: QueryTypes.SELECT, diff --git a/libs/model/src/contest/index.ts b/libs/model/src/contest/index.ts index 445d9d5cf11..8bc5ddd9e89 100644 --- a/libs/model/src/contest/index.ts +++ b/libs/model/src/contest/index.ts @@ -1,4 +1,5 @@ export * from './CancelContestManagerMetadata.command'; +export * from './CheckContests.command'; export * from './Contests.projection'; export * from './CreateContestManagerMetadata.command'; export * from './FarcasterCastCreatedWebhook.command'; diff --git a/libs/model/test/contest/check-contests.spec.ts b/libs/model/test/contest/check-contests.spec.ts new file mode 100644 index 00000000000..a9ef66d3a13 --- /dev/null +++ b/libs/model/test/contest/check-contests.spec.ts @@ -0,0 +1,207 @@ +import Sinon from 'sinon'; + +import { Actor, command, dispose, EventNames } from '@hicommonwealth/core'; +import { + commonProtocol, + ContestWorker, + emitEvent, + models, +} from '@hicommonwealth/model'; +import { expect } from 'chai'; +import { Contests } from 'model/src/contest'; +import { CheckContests } from 'model/src/contest/CheckContests.command'; +import { literal } from 'sequelize'; +import { afterAll, beforeAll, describe, test } from 'vitest'; +import { bootstrap_testing, seed } from '../../src/tester'; +import { drainOutbox } from '../utils'; + +describe('Check Contests', () => { + const addressId = 444; + const address = '0x0'; + const communityId = 'ethhh'; + const threadId = 888; + const threadTitle = 'Hello There'; + const contestAddress = '0x1'; + const contestId = 0; + const contentId = 1; + let topicId: number = 0; + + beforeAll(async () => { + await bootstrap_testing(import.meta); + + const [chainNode] = await seed('ChainNode', { contracts: [] }); + const [user] = await seed( + 'User', + { + isAdmin: false, + selected_community_id: undefined, + }, + //{ mock: true, log: true }, + ); + const [community] = await seed('Community', { + id: communityId, + chain_node_id: chainNode!.id, + lifetime_thread_count: 0, + profile_count: 1, + Addresses: [ + { + id: addressId, + user_id: user!.id, + address, + role: 'member', + }, + ], + topics: [ + { + id: topicId, + name: 'hello', + community_id: communityId, + group_ids: [], + }, + ], + contest_managers: [ + { + contest_address: contestAddress, + cancelled: false, + ended: false, + is_farcaster_contest: false, + topic_id: topicId, + interval: 0, + contests: [ + { + contest_address: contestAddress, + contest_id: contestId, + start_time: new Date(), + end_time: new Date(new Date().getTime() + 60 * 60 * 1000), + score: [], + }, + ], + }, + ], + }); + await seed('Thread', { + id: threadId, + community_id: communityId, + address_id: addressId, + topic_id: topicId, + deleted_at: undefined, + pinned: false, + read_only: false, + reaction_weights_sum: '0', + }); + }); + + afterAll(async () => { + Sinon.restore(); + await dispose()(); + }); + + test('Should add onchain vote to unvoted contest', async () => { + const addContentStub = Sinon.stub( + commonProtocol.contestHelper, + 'addContentBatch', + ).resolves([]); + + await emitEvent(models.Outbox, [ + { + event_name: EventNames.ThreadCreated, + event_payload: { + id: threadId, + community_id: communityId, + address_id: addressId, + title: threadTitle, + created_by: address, + canvas_signed_data: '', + canvas_msg_id: '', + kind: '', + stage: '', + body: '', + view_count: 0, + reaction_count: 0, + reaction_weights_sum: '0', + comment_count: 0, + deleted_at: undefined, + pinned: false, + read_only: false, + topic_id: topicId, + contestManagers: [ + { + contest_address: contestAddress, + }, + ], + }, + }, + ]); + + await drainOutbox(['ThreadCreated'], ContestWorker); + + expect(addContentStub.called, 'addContent was not called').to.be.true; + + await emitEvent(models.Outbox, [ + { + event_name: EventNames.ContestContentAdded, + event_payload: { + content_id: 0, + content_url: '/ethhh/discussion/888', + contest_address: contestAddress, + creator_address: address, + }, + }, + ]); + + await drainOutbox(['ContestContentAdded'], Contests); + + const voteContentStub = Sinon.stub( + commonProtocol.contestHelper, + 'voteContentBatch', + ).resolves([]); + + // simulate contest will end in 2 hours + await models.Contest.update( + { + end_time: literal(`NOW() + INTERVAL '2 hours'`), + }, + { + where: { + contest_address: contestAddress, + contest_id: contestId, + }, + }, + ); + + await command(CheckContests(), { + actor: {} as Actor, + payload: { id: '' }, + }); + + expect(voteContentStub.called, 'vote should not be cast yet').to.be.false; + + // simulate contest will end in less than 1 hour + await models.Contest.update( + { + end_time: literal(`NOW() + INTERVAL '50 minutes'`), + }, + { + where: { + contest_address: contestAddress, + contest_id: contestId, + }, + }, + ); + + await command(CheckContests(), { + actor: {} as Actor, + payload: { id: '' }, + }); + + expect(voteContentStub.called, 'vote should have been cast').to.be.true; + expect( + voteContentStub.args[0][1].startsWith('0x'), + 'using valid wallet address', + ).to.be.true; + expect(voteContentStub.args[0][1]).has.length( + 42, + 'using valid wallet address', + ); + }); +}); diff --git a/libs/model/test/contest-worker/contest-worker-policy-lifecycle.spec.ts b/libs/model/test/contest/contest-worker-policy-lifecycle.spec.ts similarity index 97% rename from libs/model/test/contest-worker/contest-worker-policy-lifecycle.spec.ts rename to libs/model/test/contest/contest-worker-policy-lifecycle.spec.ts index 9b696676f27..860d676c24b 100644 --- a/libs/model/test/contest-worker/contest-worker-policy-lifecycle.spec.ts +++ b/libs/model/test/contest/contest-worker-policy-lifecycle.spec.ts @@ -74,8 +74,6 @@ describe('Contest Worker Policy Lifecycle', () => { }, ], }); - expect(community!.contest_managers!.length).to.eq(1); - expect(community!.contest_managers![0].contests!.length).to.eq(1); await seed('Thread', { id: threadId, community_id: communityId, diff --git a/packages/commonwealth/server/workers/commonwealthConsumer/commonwealthConsumer.ts b/packages/commonwealth/server/workers/commonwealthConsumer/commonwealthConsumer.ts index e7d9612d410..6a058409e04 100644 --- a/packages/commonwealth/server/workers/commonwealthConsumer/commonwealthConsumer.ts +++ b/packages/commonwealth/server/workers/commonwealthConsumer/commonwealthConsumer.ts @@ -142,16 +142,37 @@ export async function setupCommonwealthConsumer(): Promise { function startRolloverLoop() { log.info('Starting rollover loop'); + const loop = async () => { + try { + await command( + Contest.CheckContests(), + { + actor: {} as Actor, + payload: { id: '' }, + }, + false, + ); + } catch (err) { + log.error(err); + } + + try { + await command( + Contest.PerformContestRollovers(), + { + actor: {} as Actor, + payload: { id: '' }, + }, + false, + ); + } catch (err) { + log.error(err); + } + }; + // TODO: move to external service triggered via scheduler? setInterval(() => { - command( - Contest.PerformContestRollovers(), - { - actor: {} as Actor, - payload: { id: '' }, - }, - false, - ).catch(console.error); + loop(); }, 1_000 * 60); } From 8673db7c18f030af8a6171b71f0e06875eb7c281 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Thu, 21 Nov 2024 11:59:48 -0800 Subject: [PATCH 04/17] lint --- libs/model/test/contest/check-contests.spec.ts | 5 ++--- .../test/contest/contest-worker-policy-lifecycle.spec.ts | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/libs/model/test/contest/check-contests.spec.ts b/libs/model/test/contest/check-contests.spec.ts index a9ef66d3a13..72dfb6520dd 100644 --- a/libs/model/test/contest/check-contests.spec.ts +++ b/libs/model/test/contest/check-contests.spec.ts @@ -23,8 +23,7 @@ describe('Check Contests', () => { const threadTitle = 'Hello There'; const contestAddress = '0x1'; const contestId = 0; - const contentId = 1; - let topicId: number = 0; + const topicId: number = 0; beforeAll(async () => { await bootstrap_testing(import.meta); @@ -38,7 +37,7 @@ describe('Check Contests', () => { }, //{ mock: true, log: true }, ); - const [community] = await seed('Community', { + await seed('Community', { id: communityId, chain_node_id: chainNode!.id, lifetime_thread_count: 0, diff --git a/libs/model/test/contest/contest-worker-policy-lifecycle.spec.ts b/libs/model/test/contest/contest-worker-policy-lifecycle.spec.ts index 860d676c24b..64bebcc7472 100644 --- a/libs/model/test/contest/contest-worker-policy-lifecycle.spec.ts +++ b/libs/model/test/contest/contest-worker-policy-lifecycle.spec.ts @@ -18,8 +18,7 @@ describe('Contest Worker Policy Lifecycle', () => { const threadTitle = 'Hello There'; const contestAddress = '0x1'; const contestId = 0; - const contentId = 1; - let topicId: number = 0; + const topicId: number = 0; beforeAll(async () => { await bootstrap_testing(import.meta); @@ -33,7 +32,7 @@ describe('Contest Worker Policy Lifecycle', () => { }, //{ mock: true, log: true }, ); - const [community] = await seed('Community', { + await seed('Community', { id: communityId, chain_node_id: chainNode!.id, lifetime_thread_count: 0, From 228bf08b212897745a952a0c17bc83038e4bae48 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Thu, 21 Nov 2024 12:06:49 -0800 Subject: [PATCH 05/17] verbose log err --- libs/model/src/contest/CheckContests.command.ts | 2 +- libs/model/src/contest/PerformContestRollovers.command.ts | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/libs/model/src/contest/CheckContests.command.ts b/libs/model/src/contest/CheckContests.command.ts index 514813f1f7c..6fd47cfe03d 100644 --- a/libs/model/src/contest/CheckContests.command.ts +++ b/libs/model/src/contest/CheckContests.command.ts @@ -64,7 +64,7 @@ export function CheckContests(): Command { ); if (errors.length > 0) { - log.warn(`CheckContests: failed with errors: ${errors.join(', ')}"`); + log.error(`CheckContests: failed with errors: ${errors.join(', ')}"`); } }, }; diff --git a/libs/model/src/contest/PerformContestRollovers.command.ts b/libs/model/src/contest/PerformContestRollovers.command.ts index 59e0b8b51e0..eb27a26456b 100644 --- a/libs/model/src/contest/PerformContestRollovers.command.ts +++ b/libs/model/src/contest/PerformContestRollovers.command.ts @@ -122,10 +122,8 @@ export function PerformContestRollovers(): Command< ); if (errors.length > 0) { - log.warn( - `GetAllContests performContestRollovers: failed with errors: ${errors.join( - ', ', - )}"`, + log.error( + `PerformContestRollovers: failed with errors: ${errors.join(', ')}"`, ); } }, From 917975d674124ecab800ecf6637d245ad4454029 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Thu, 21 Nov 2024 12:17:46 -0800 Subject: [PATCH 06/17] add burner private key for CI --- .github/workflows/CI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index ac03b1b392a..74da2d11a48 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -7,7 +7,7 @@ env: NODE_ENV: 'test' ROLLBAR_ENV: 'GitHubCI' TEST_WITHOUT_LOGS: 'true' - PRIVATE_KEY: 'web3-pk' + PRIVATE_KEY: '83c65f24efbc8f4bc54ad425e897fc3ea01f760d5e71671a30eacb075ebe2313' USES_DOCKER_PGSQL: true PORT: 8080 REDIS_URL: redis://localhost:6379 From e23133d109e9afa6392d2c00813f00d0828f70e1 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Thu, 21 Nov 2024 12:25:31 -0800 Subject: [PATCH 07/17] fix key --- .github/workflows/CI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 74da2d11a48..3ff3cdf25a3 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -7,7 +7,7 @@ env: NODE_ENV: 'test' ROLLBAR_ENV: 'GitHubCI' TEST_WITHOUT_LOGS: 'true' - PRIVATE_KEY: '83c65f24efbc8f4bc54ad425e897fc3ea01f760d5e71671a30eacb075ebe2313' + PRIVATE_KEY: '0x83c65f24efbc8f4bc54ad425e897fc3ea01f760d5e71671a30eacb075ebe2313' USES_DOCKER_PGSQL: true PORT: 8080 REDIS_URL: redis://localhost:6379 From b077d5391774f892cd6cc4ba4b276bf8bba5b4f9 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Thu, 21 Nov 2024 12:34:52 -0800 Subject: [PATCH 08/17] lint --- .../server/workers/commonwealthConsumer/commonwealthConsumer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/commonwealth/server/workers/commonwealthConsumer/commonwealthConsumer.ts b/packages/commonwealth/server/workers/commonwealthConsumer/commonwealthConsumer.ts index 6a058409e04..f697c64a144 100644 --- a/packages/commonwealth/server/workers/commonwealthConsumer/commonwealthConsumer.ts +++ b/packages/commonwealth/server/workers/commonwealthConsumer/commonwealthConsumer.ts @@ -172,7 +172,7 @@ function startRolloverLoop() { // TODO: move to external service triggered via scheduler? setInterval(() => { - loop(); + loop().catch(console.error); }, 1_000 * 60); } From 366580b6a4eab04ebe9800187ec33c35fc4219f4 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Thu, 21 Nov 2024 13:11:07 -0800 Subject: [PATCH 09/17] move to policy --- libs/core/src/integration/events.schemas.ts | 4 + libs/core/src/integration/events.ts | 10 + .../src/contest/CheckContests.command.ts | 71 ------- .../PerformContestRollovers.command.ts | 131 ------------- libs/model/src/contest/index.ts | 2 - .../src/policies/ContestWorker.policy.ts | 182 +++++++++++++++++- .../model/test/contest/check-contests.spec.ts | 15 +- .../contest-worker-policy-lifecycle.spec.ts | 28 +-- .../commonwealthConsumer.ts | 28 +-- 9 files changed, 222 insertions(+), 249 deletions(-) delete mode 100644 libs/model/src/contest/CheckContests.command.ts delete mode 100644 libs/model/src/contest/PerformContestRollovers.command.ts diff --git a/libs/core/src/integration/events.schemas.ts b/libs/core/src/integration/events.schemas.ts index 0267466738d..3b00244c3de 100644 --- a/libs/core/src/integration/events.schemas.ts +++ b/libs/core/src/integration/events.schemas.ts @@ -262,3 +262,7 @@ export const FarcasterReplyCastCreated = FarcasterCast.describe( export const FarcasterVoteCreated = FarcasterAction.extend({ contest_address: z.string(), }).describe('When a farcaster action is initiated on a cast reply'); + +export const CheckContests = z.object({}); + +export const RolloverContests = z.object({}); diff --git a/libs/core/src/integration/events.ts b/libs/core/src/integration/events.ts index b546c221884..72771fe6581 100644 --- a/libs/core/src/integration/events.ts +++ b/libs/core/src/integration/events.ts @@ -35,6 +35,8 @@ export enum EventNames { FarcasterCastCreated = 'FarcasterCastCreated', FarcasterReplyCastCreated = 'FarcasterReplyCastCreated', FarcasterVoteCreated = 'FarcasterVoteCreated', + CheckContests = 'CheckContests', + RolloverContests = 'RolloverContests', // Preferences SubscriptionPreferencesUpdated = 'SubscriptionPreferencesUpdated', @@ -113,6 +115,14 @@ export type EventPairs = event_name: EventNames.FarcasterVoteCreated; event_payload: z.infer; } + | { + event_name: EventNames.CheckContests; + event_payload: z.infer; + } + | { + event_name: EventNames.RolloverContests; + event_payload: z.infer; + } | { event_name: EventNames.DiscordThreadCreated; event_payload: z.infer; diff --git a/libs/model/src/contest/CheckContests.command.ts b/libs/model/src/contest/CheckContests.command.ts deleted file mode 100644 index 6fd47cfe03d..00000000000 --- a/libs/model/src/contest/CheckContests.command.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Actor, logger, query, type Command } from '@hicommonwealth/core'; -import * as schemas from '@hicommonwealth/schemas'; -import moment from 'moment'; -import Web3 from 'web3'; -import { config } from '../config'; -import { createOnchainContestVote } from '../policies/contest-utils'; -import { GetActiveContestManagers } from './GetActiveContestManagers.query'; - -const log = logger(import.meta); - -const getPrivateWalletAddress = () => { - const web3 = new Web3(); - const privateKey = config.WEB3.PRIVATE_KEY; - const account = web3.eth.accounts.privateKeyToAccount(privateKey); - const publicAddress = account.address; - return publicAddress; -}; - -export function CheckContests(): Command { - return { - ...schemas.CheckContests, - auth: [], - body: async () => { - const activeContestManagers = await query(GetActiveContestManagers(), { - actor: {} as Actor, - payload: {}, - }); - // find active contests that have content with no upvotes and will end in one hour - const contestsWithoutVote = activeContestManagers!.filter( - (contestManager) => - contestManager.actions.some((action) => action.action === 'added') && - !contestManager.actions.some( - (action) => action.action === 'upvoted', - ) && - moment(contestManager.end_time).diff(moment(), 'minutes') < 60, - ); - - const promises = contestsWithoutVote.map(async (contestManager) => { - // add onchain vote to the first content - const firstContent = contestManager.actions.find( - (action) => action.action === 'added', - ); - - await createOnchainContestVote({ - contestManagers: [ - { - url: contestManager.url, - contest_address: contestManager.contest_address, - content_id: firstContent!.content_id, - }, - ], - content_url: firstContent!.content_url!, - author_address: getPrivateWalletAddress(), - }); - }); - - const promiseResults = await Promise.allSettled(promises); - - const errors = promiseResults - .filter(({ status }) => status === 'rejected') - .map( - (result) => - (result as PromiseRejectedResult).reason || '', - ); - - if (errors.length > 0) { - log.error(`CheckContests: failed with errors: ${errors.join(', ')}"`); - } - }, - }; -} diff --git a/libs/model/src/contest/PerformContestRollovers.command.ts b/libs/model/src/contest/PerformContestRollovers.command.ts deleted file mode 100644 index eb27a26456b..00000000000 --- a/libs/model/src/contest/PerformContestRollovers.command.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { logger, type Command } from '@hicommonwealth/core'; -import * as schemas from '@hicommonwealth/schemas'; -import { NeynarAPIClient } from '@neynar/nodejs-sdk'; -import { QueryTypes } from 'sequelize'; -import { config } from '../config'; -import { models } from '../database'; -import { rollOverContest } from '../services/commonProtocol/contestHelper'; -import { getChainNodeUrl } from '../utils/utils'; - -const log = logger(import.meta); - -export function PerformContestRollovers(): Command< - typeof schemas.PerformContestRollovers -> { - return { - ...schemas.PerformContestRollovers, - auth: [], - body: async () => { - const contestManagersWithEndedContest = await models.sequelize.query<{ - contest_address: string; - interval: number; - ended: boolean; - url: string; - private_url: string; - neynar_webhook_id?: string; - }>( - ` - SELECT cm.contest_address, - cm.interval, - cm.ended, - cm.neynar_webhook_id, - co.end_time, - cn.private_url, - cn.url - FROM "ContestManagers" cm - JOIN (SELECT * - FROM "Contests" - WHERE (contest_address, contest_id) IN (SELECT contest_address, MAX(contest_id) AS contest_id - FROM "Contests" - GROUP BY contest_address)) co - ON co.contest_address = cm.contest_address - AND ( - (cm.interval = 0 AND cm.ended IS NOT TRUE) - OR - cm.interval > 0 - ) - AND NOW() > co.end_time - AND cm.cancelled IS NOT TRUE - JOIN "Communities" cu ON cm.community_id = cu.id - JOIN "ChainNodes" cn ON cu.chain_node_id = cn.id; - `, - { - type: QueryTypes.SELECT, - raw: true, - }, - ); - - const contestRolloverPromises = contestManagersWithEndedContest.map( - async ({ - url, - private_url, - contest_address, - interval, - ended, - neynar_webhook_id, - }) => { - log.info(`ROLLOVER: ${contest_address}`); - - if (interval === 0 && !ended) { - // preemptively mark as ended so that rollover - // is not attempted again after failure - await models.ContestManager.update( - { - ended: true, - }, - { - where: { - contest_address, - }, - }, - ); - } - - await rollOverContest( - getChainNodeUrl({ url, private_url }), - contest_address, - interval === 0, - ); - - // clean up neynar webhooks when farcaster contest ends - if (neynar_webhook_id) { - try { - const client = new NeynarAPIClient( - config.CONTESTS.NEYNAR_API_KEY!, - ); - await client.deleteWebhook(neynar_webhook_id); - await models.ContestManager.update( - { - neynar_webhook_id: null, - neynar_webhook_secret: null, - }, - { - where: { - contest_address, - }, - }, - ); - } catch (err) { - log.warn(`failed to delete neynar webhook: ${neynar_webhook_id}`); - } - } - }, - ); - - const promiseResults = await Promise.allSettled(contestRolloverPromises); - - const errors = promiseResults - .filter(({ status }) => status === 'rejected') - .map( - (result) => - (result as PromiseRejectedResult).reason || '', - ); - - if (errors.length > 0) { - log.error( - `PerformContestRollovers: failed with errors: ${errors.join(', ')}"`, - ); - } - }, - }; -} diff --git a/libs/model/src/contest/index.ts b/libs/model/src/contest/index.ts index 8bc5ddd9e89..10c0ec7082f 100644 --- a/libs/model/src/contest/index.ts +++ b/libs/model/src/contest/index.ts @@ -1,5 +1,4 @@ export * from './CancelContestManagerMetadata.command'; -export * from './CheckContests.command'; export * from './Contests.projection'; export * from './CreateContestManagerMetadata.command'; export * from './FarcasterCastCreatedWebhook.command'; @@ -11,5 +10,4 @@ export * from './GetContest.query'; export * from './GetContestLog.query'; export * from './GetFarcasterContestCasts'; export * from './GetFarcasterUpvoteActionMetadata.query'; -export * from './PerformContestRollovers.command'; export * from './UpdateContestManagerMetadata.command'; diff --git a/libs/model/src/policies/ContestWorker.policy.ts b/libs/model/src/policies/ContestWorker.policy.ts index 12139e17a4c..d90cb6b744d 100644 --- a/libs/model/src/policies/ContestWorker.policy.ts +++ b/libs/model/src/policies/ContestWorker.policy.ts @@ -1,6 +1,11 @@ import { Actor, events, logger, Policy } from '@hicommonwealth/core'; +import { NeynarAPIClient } from '@neynar/nodejs-sdk'; +import moment from 'moment'; import { QueryTypes } from 'sequelize'; -import { Contest, models } from '..'; +import Web3 from 'web3'; +import { config, Contest, models } from '..'; +import { GetActiveContestManagers } from '../contest'; +import { rollOverContest } from '../services/commonProtocol/contestHelper'; import { buildThreadContentUrl, getChainNodeUrl } from '../utils'; import { createOnchainContestContent, @@ -12,6 +17,16 @@ const log = logger(import.meta); const inputs = { ThreadCreated: events.ThreadCreated, ThreadUpvoted: events.ThreadUpvoted, + CheckContests: events.CheckContests, + RolloverContests: events.RolloverContests, +}; + +const getPrivateWalletAddress = () => { + const web3 = new Web3(); + const privateKey = config.WEB3.PRIVATE_KEY; + const account = web3.eth.accounts.privateKeyToAccount(privateKey); + const publicAddress = account.address; + return publicAddress; }; export function ContestWorker(): Policy { @@ -129,6 +144,171 @@ export function ContestWorker(): Policy { author_address: payload.address!, }); }, + CheckContests: async ({ payload }) => { + const activeContestManagers = await GetActiveContestManagers().body({ + actor: {} as Actor, + payload: {}, + }); + // find active contests that have content with no upvotes and will end in one hour + const contestsWithoutVote = activeContestManagers!.filter( + (contestManager) => + contestManager.actions.some( + (action) => action.action === 'added', + ) && + !contestManager.actions.some( + (action) => action.action === 'upvoted', + ) && + moment(contestManager.end_time).diff(moment(), 'minutes') < 60, + ); + + const promises = contestsWithoutVote.map(async (contestManager) => { + // add onchain vote to the first content + const firstContent = contestManager.actions.find( + (action) => action.action === 'added', + ); + + await createOnchainContestVote({ + contestManagers: [ + { + url: contestManager.url, + contest_address: contestManager.contest_address, + content_id: firstContent!.content_id, + }, + ], + content_url: firstContent!.content_url!, + author_address: getPrivateWalletAddress(), + }); + }); + + const promiseResults = await Promise.allSettled(promises); + + const errors = promiseResults + .filter(({ status }) => status === 'rejected') + .map( + (result) => + (result as PromiseRejectedResult).reason || '', + ); + + if (errors.length > 0) { + log.error(`CheckContests: failed with errors: ${errors.join(', ')}"`); + } + }, + RolloverContests: async ({ payload }) => { + const contestManagersWithEndedContest = await models.sequelize.query<{ + contest_address: string; + interval: number; + ended: boolean; + url: string; + private_url: string; + neynar_webhook_id?: string; + }>( + ` + SELECT cm.contest_address, + cm.interval, + cm.ended, + cm.neynar_webhook_id, + co.end_time, + cn.private_url, + cn.url + FROM "ContestManagers" cm + JOIN (SELECT * + FROM "Contests" + WHERE (contest_address, contest_id) IN (SELECT contest_address, MAX(contest_id) AS contest_id + FROM "Contests" + GROUP BY contest_address)) co + ON co.contest_address = cm.contest_address + AND ( + (cm.interval = 0 AND cm.ended IS NOT TRUE) + OR + cm.interval > 0 + ) + AND NOW() > co.end_time + AND cm.cancelled IS NOT TRUE + JOIN "Communities" cu ON cm.community_id = cu.id + JOIN "ChainNodes" cn ON cu.chain_node_id = cn.id; + `, + { + type: QueryTypes.SELECT, + raw: true, + }, + ); + + const contestRolloverPromises = contestManagersWithEndedContest.map( + async ({ + url, + private_url, + contest_address, + interval, + ended, + neynar_webhook_id, + }) => { + log.info(`ROLLOVER: ${contest_address}`); + + if (interval === 0 && !ended) { + // preemptively mark as ended so that rollover + // is not attempted again after failure + await models.ContestManager.update( + { + ended: true, + }, + { + where: { + contest_address, + }, + }, + ); + } + + await rollOverContest( + getChainNodeUrl({ url, private_url }), + contest_address, + interval === 0, + ); + + // clean up neynar webhooks when farcaster contest ends + if (neynar_webhook_id) { + try { + const client = new NeynarAPIClient( + config.CONTESTS.NEYNAR_API_KEY!, + ); + await client.deleteWebhook(neynar_webhook_id); + await models.ContestManager.update( + { + neynar_webhook_id: null, + neynar_webhook_secret: null, + }, + { + where: { + contest_address, + }, + }, + ); + } catch (err) { + log.warn( + `failed to delete neynar webhook: ${neynar_webhook_id}`, + ); + } + } + }, + ); + + const promiseResults = await Promise.allSettled( + contestRolloverPromises, + ); + + const errors = promiseResults + .filter(({ status }) => status === 'rejected') + .map( + (result) => + (result as PromiseRejectedResult).reason || '', + ); + + if (errors.length > 0) { + log.error( + `PerformContestRollovers: failed with errors: ${errors.join(', ')}"`, + ); + } + }, }, }; } diff --git a/libs/model/test/contest/check-contests.spec.ts b/libs/model/test/contest/check-contests.spec.ts index 72dfb6520dd..0fdd2aa3cdc 100644 --- a/libs/model/test/contest/check-contests.spec.ts +++ b/libs/model/test/contest/check-contests.spec.ts @@ -1,6 +1,6 @@ import Sinon from 'sinon'; -import { Actor, command, dispose, EventNames } from '@hicommonwealth/core'; +import { dispose, EventNames, handleEvent } from '@hicommonwealth/core'; import { commonProtocol, ContestWorker, @@ -9,7 +9,6 @@ import { } from '@hicommonwealth/model'; import { expect } from 'chai'; import { Contests } from 'model/src/contest'; -import { CheckContests } from 'model/src/contest/CheckContests.command'; import { literal } from 'sequelize'; import { afterAll, beforeAll, describe, test } from 'vitest'; import { bootstrap_testing, seed } from '../../src/tester'; @@ -168,9 +167,9 @@ describe('Check Contests', () => { }, ); - await command(CheckContests(), { - actor: {} as Actor, - payload: { id: '' }, + await handleEvent(ContestWorker(), { + name: EventNames.CheckContests, + payload: {}, }); expect(voteContentStub.called, 'vote should not be cast yet').to.be.false; @@ -188,9 +187,9 @@ describe('Check Contests', () => { }, ); - await command(CheckContests(), { - actor: {} as Actor, - payload: { id: '' }, + await handleEvent(ContestWorker(), { + name: EventNames.CheckContests, + payload: {}, }); expect(voteContentStub.called, 'vote should have been cast').to.be.true; diff --git a/libs/model/test/contest/contest-worker-policy-lifecycle.spec.ts b/libs/model/test/contest/contest-worker-policy-lifecycle.spec.ts index 64bebcc7472..9fe68070621 100644 --- a/libs/model/test/contest/contest-worker-policy-lifecycle.spec.ts +++ b/libs/model/test/contest/contest-worker-policy-lifecycle.spec.ts @@ -1,10 +1,10 @@ import { expect } from 'chai'; import Sinon from 'sinon'; -import { Actor, command, dispose, EventNames } from '@hicommonwealth/core'; +import { dispose, EventNames, handleEvent } from '@hicommonwealth/core'; import { literal } from 'sequelize'; import { afterAll, beforeAll, describe, test } from 'vitest'; -import { commonProtocol, Contest, emitEvent, models } from '../../src'; +import { commonProtocol, emitEvent, models } from '../../src'; import { Contests } from '../../src/contest'; import { ContestWorker } from '../../src/policies'; import { bootstrap_testing, seed } from '../../src/tester'; @@ -179,14 +179,10 @@ describe('Contest Worker Policy Lifecycle', () => { expect(voteContentStub.called, 'voteContent was not called').to.be.true; - command( - Contest.PerformContestRollovers(), - { - actor: {} as Actor, - payload: { id: '' }, - }, - false, - ); + await handleEvent(ContestWorker(), { + name: EventNames.RolloverContests, + payload: {}, + }); const contestManagerBeforeContestEnded = await models.ContestManager.findByPk(contestAddress); @@ -209,14 +205,10 @@ describe('Contest Worker Policy Lifecycle', () => { }, ); - await command( - Contest.PerformContestRollovers(), - { - actor: {} as Actor, - payload: { id: '' }, - }, - false, - ); + await handleEvent(ContestWorker(), { + name: EventNames.RolloverContests, + payload: {}, + }); const contestManagerAfterContestEnded = await models.ContestManager.findByPk(contestAddress); diff --git a/packages/commonwealth/server/workers/commonwealthConsumer/commonwealthConsumer.ts b/packages/commonwealth/server/workers/commonwealthConsumer/commonwealthConsumer.ts index f697c64a144..1f8eb63faf6 100644 --- a/packages/commonwealth/server/workers/commonwealthConsumer/commonwealthConsumer.ts +++ b/packages/commonwealth/server/workers/commonwealthConsumer/commonwealthConsumer.ts @@ -8,11 +8,11 @@ import { startHealthCheckLoop, } from '@hicommonwealth/adapters'; import { - Actor, Broker, BrokerSubscriptions, + EventNames, broker, - command, + handleEvent, logger, stats, } from '@hicommonwealth/core'; @@ -144,27 +144,19 @@ function startRolloverLoop() { const loop = async () => { try { - await command( - Contest.CheckContests(), - { - actor: {} as Actor, - payload: { id: '' }, - }, - false, - ); + await handleEvent(ContestWorker(), { + name: EventNames.CheckContests, + payload: {}, + }); } catch (err) { log.error(err); } try { - await command( - Contest.PerformContestRollovers(), - { - actor: {} as Actor, - payload: { id: '' }, - }, - false, - ); + await handleEvent(ContestWorker(), { + name: EventNames.RolloverContests, + payload: {}, + }); } catch (err) { log.error(err); } From 3de6fdc780edb0eff3aad910f9dfae01db09b8c5 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Thu, 21 Nov 2024 13:16:18 -0800 Subject: [PATCH 10/17] fix type --- libs/core/src/integration/outbox.schema.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/libs/core/src/integration/outbox.schema.ts b/libs/core/src/integration/outbox.schema.ts index 5605365b806..8d6e0ae44cb 100644 --- a/libs/core/src/integration/outbox.schema.ts +++ b/libs/core/src/integration/outbox.schema.ts @@ -125,6 +125,18 @@ export const Outbox = z.union([ event_payload: events.FarcasterVoteCreated, }) .merge(BaseOutboxProperties), + z + .object({ + event_name: z.literal(EventNames.CheckContests), + event_payload: events.CheckContests, + }) + .merge(BaseOutboxProperties), + z + .object({ + event_name: z.literal(EventNames.RolloverContests), + event_payload: events.RolloverContests, + }) + .merge(BaseOutboxProperties), z .object({ event_name: z.literal(EventNames.DiscordThreadCreated), From 923a527d8a7cf031bae1c20dcd478895e9567a66 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Thu, 21 Nov 2024 13:18:13 -0800 Subject: [PATCH 11/17] lint --- libs/model/src/policies/ContestWorker.policy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/model/src/policies/ContestWorker.policy.ts b/libs/model/src/policies/ContestWorker.policy.ts index d90cb6b744d..419d56365b3 100644 --- a/libs/model/src/policies/ContestWorker.policy.ts +++ b/libs/model/src/policies/ContestWorker.policy.ts @@ -144,7 +144,7 @@ export function ContestWorker(): Policy { author_address: payload.address!, }); }, - CheckContests: async ({ payload }) => { + CheckContests: async () => { const activeContestManagers = await GetActiveContestManagers().body({ actor: {} as Actor, payload: {}, @@ -193,7 +193,7 @@ export function ContestWorker(): Policy { log.error(`CheckContests: failed with errors: ${errors.join(', ')}"`); } }, - RolloverContests: async ({ payload }) => { + RolloverContests: async () => { const contestManagersWithEndedContest = await models.sequelize.query<{ contest_address: string; interval: number; From 6cb91a989d44d676997202d5311e17a854334dac Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Thu, 21 Nov 2024 13:23:14 -0800 Subject: [PATCH 12/17] remove schemas --- libs/schemas/src/commands/contest.schemas.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/libs/schemas/src/commands/contest.schemas.ts b/libs/schemas/src/commands/contest.schemas.ts index 1fc3ef4462f..d4268422795 100644 --- a/libs/schemas/src/commands/contest.schemas.ts +++ b/libs/schemas/src/commands/contest.schemas.ts @@ -78,16 +78,6 @@ export const ResumeContestManagerMetadata = { context: AuthContext, }; -export const PerformContestRollovers = { - input: z.object({ id: z.string() }), - output: z.object({}), -}; - -export const CheckContests = { - input: z.object({ id: z.string() }), - output: z.object({}), -}; - export const FarcasterCast = z.object({ object: z.string(), hash: z.string(), From 05f009174697d80457ad1b616e8f3087bba347b1 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Thu, 21 Nov 2024 13:40:14 -0800 Subject: [PATCH 13/17] types --- libs/core/src/integration/events.ts | 8 -------- libs/core/src/integration/outbox.schema.ts | 12 ------------ 2 files changed, 20 deletions(-) diff --git a/libs/core/src/integration/events.ts b/libs/core/src/integration/events.ts index 72771fe6581..47558f6566c 100644 --- a/libs/core/src/integration/events.ts +++ b/libs/core/src/integration/events.ts @@ -115,14 +115,6 @@ export type EventPairs = event_name: EventNames.FarcasterVoteCreated; event_payload: z.infer; } - | { - event_name: EventNames.CheckContests; - event_payload: z.infer; - } - | { - event_name: EventNames.RolloverContests; - event_payload: z.infer; - } | { event_name: EventNames.DiscordThreadCreated; event_payload: z.infer; diff --git a/libs/core/src/integration/outbox.schema.ts b/libs/core/src/integration/outbox.schema.ts index 8d6e0ae44cb..5605365b806 100644 --- a/libs/core/src/integration/outbox.schema.ts +++ b/libs/core/src/integration/outbox.schema.ts @@ -125,18 +125,6 @@ export const Outbox = z.union([ event_payload: events.FarcasterVoteCreated, }) .merge(BaseOutboxProperties), - z - .object({ - event_name: z.literal(EventNames.CheckContests), - event_payload: events.CheckContests, - }) - .merge(BaseOutboxProperties), - z - .object({ - event_name: z.literal(EventNames.RolloverContests), - event_payload: events.RolloverContests, - }) - .merge(BaseOutboxProperties), z .object({ event_name: z.literal(EventNames.DiscordThreadCreated), From 6ca546dcd2172a18103820784da1be46090c4a77 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Fri, 22 Nov 2024 09:42:44 -0800 Subject: [PATCH 14/17] tweak loop and policy --- libs/core/src/integration/events.schemas.ts | 2 +- libs/core/src/integration/events.ts | 3 +- .../src/policies/ContestWorker.policy.ts | 281 +++++++++--------- .../model/test/contest/check-contests.spec.ts | 4 +- .../commonwealthConsumer.ts | 11 +- 5 files changed, 147 insertions(+), 154 deletions(-) diff --git a/libs/core/src/integration/events.schemas.ts b/libs/core/src/integration/events.schemas.ts index 3b00244c3de..877012bd847 100644 --- a/libs/core/src/integration/events.schemas.ts +++ b/libs/core/src/integration/events.schemas.ts @@ -265,4 +265,4 @@ export const FarcasterVoteCreated = FarcasterAction.extend({ export const CheckContests = z.object({}); -export const RolloverContests = z.object({}); +export const ContestRolloverTimerTicked = z.object({}); diff --git a/libs/core/src/integration/events.ts b/libs/core/src/integration/events.ts index 47558f6566c..5c7d29fc1d9 100644 --- a/libs/core/src/integration/events.ts +++ b/libs/core/src/integration/events.ts @@ -35,8 +35,7 @@ export enum EventNames { FarcasterCastCreated = 'FarcasterCastCreated', FarcasterReplyCastCreated = 'FarcasterReplyCastCreated', FarcasterVoteCreated = 'FarcasterVoteCreated', - CheckContests = 'CheckContests', - RolloverContests = 'RolloverContests', + ContestRolloverTimerTicked = 'ContestRolloverTimerTicked', // Preferences SubscriptionPreferencesUpdated = 'SubscriptionPreferencesUpdated', diff --git a/libs/model/src/policies/ContestWorker.policy.ts b/libs/model/src/policies/ContestWorker.policy.ts index 419d56365b3..8116a799d80 100644 --- a/libs/model/src/policies/ContestWorker.policy.ts +++ b/libs/model/src/policies/ContestWorker.policy.ts @@ -17,16 +17,7 @@ const log = logger(import.meta); const inputs = { ThreadCreated: events.ThreadCreated, ThreadUpvoted: events.ThreadUpvoted, - CheckContests: events.CheckContests, - RolloverContests: events.RolloverContests, -}; - -const getPrivateWalletAddress = () => { - const web3 = new Web3(); - const privateKey = config.WEB3.PRIVATE_KEY; - const account = web3.eth.accounts.privateKeyToAccount(privateKey); - const publicAddress = account.address; - return publicAddress; + ContestRolloverTimerTicked: events.ContestRolloverTimerTicked, }; export function ContestWorker(): Policy { @@ -144,65 +135,86 @@ export function ContestWorker(): Policy { author_address: payload.address!, }); }, - CheckContests: async () => { - const activeContestManagers = await GetActiveContestManagers().body({ - actor: {} as Actor, - payload: {}, - }); - // find active contests that have content with no upvotes and will end in one hour - const contestsWithoutVote = activeContestManagers!.filter( - (contestManager) => - contestManager.actions.some( - (action) => action.action === 'added', - ) && - !contestManager.actions.some( - (action) => action.action === 'upvoted', - ) && - moment(contestManager.end_time).diff(moment(), 'minutes') < 60, - ); + ContestRolloverTimerTicked: async () => { + try { + await checkContests(); + } catch (err) { + log.error('error checking contests', err as Error); + } + try { + await rolloverContests(); + } catch (err) { + log.error('error rolling over contests', err as Error); + } + }, + }, + }; +} - const promises = contestsWithoutVote.map(async (contestManager) => { - // add onchain vote to the first content - const firstContent = contestManager.actions.find( - (action) => action.action === 'added', - ); +const getPrivateWalletAddress = () => { + const web3 = new Web3(); + const privateKey = config.WEB3.PRIVATE_KEY; + const account = web3.eth.accounts.privateKeyToAccount(privateKey); + const publicAddress = account.address; + return publicAddress; +}; - await createOnchainContestVote({ - contestManagers: [ - { - url: contestManager.url, - contest_address: contestManager.contest_address, - content_id: firstContent!.content_id, - }, - ], - content_url: firstContent!.content_url!, - author_address: getPrivateWalletAddress(), - }); - }); +const checkContests = async () => { + const activeContestManagers = await GetActiveContestManagers().body({ + actor: {} as Actor, + payload: {}, + }); + // find active contests that have content with no upvotes and will end in one hour + const contestsWithoutVote = activeContestManagers!.filter( + (contestManager) => + contestManager.actions.some((action) => action.action === 'added') && + !contestManager.actions.some((action) => action.action === 'upvoted') && + moment(contestManager.end_time).diff(moment(), 'minutes') < 60, + ); - const promiseResults = await Promise.allSettled(promises); + const promises = contestsWithoutVote.map(async (contestManager) => { + // add onchain vote to the first content + const firstContent = contestManager.actions.find( + (action) => action.action === 'added', + ); - const errors = promiseResults - .filter(({ status }) => status === 'rejected') - .map( - (result) => - (result as PromiseRejectedResult).reason || '', - ); + await createOnchainContestVote({ + contestManagers: [ + { + url: contestManager.url, + contest_address: contestManager.contest_address, + content_id: firstContent!.content_id, + }, + ], + content_url: firstContent!.content_url!, + author_address: getPrivateWalletAddress(), + }); + }); - if (errors.length > 0) { - log.error(`CheckContests: failed with errors: ${errors.join(', ')}"`); - } - }, - RolloverContests: async () => { - const contestManagersWithEndedContest = await models.sequelize.query<{ - contest_address: string; - interval: number; - ended: boolean; - url: string; - private_url: string; - neynar_webhook_id?: string; - }>( - ` + const promiseResults = await Promise.allSettled(promises); + + const errors = promiseResults + .filter(({ status }) => status === 'rejected') + .map( + (result) => + (result as PromiseRejectedResult).reason || '', + ); + + if (errors.length > 0) { + log.error(`CheckContests: failed with errors: ${errors.join(', ')}"`); + } +}; + +const rolloverContests = async () => { + const contestManagersWithEndedContest = await models.sequelize.query<{ + contest_address: string; + interval: number; + ended: boolean; + url: string; + private_url: string; + neynar_webhook_id?: string; + }>( + ` SELECT cm.contest_address, cm.interval, cm.ended, @@ -227,88 +239,79 @@ export function ContestWorker(): Policy { JOIN "Communities" cu ON cm.community_id = cu.id JOIN "ChainNodes" cn ON cu.chain_node_id = cn.id; `, - { - type: QueryTypes.SELECT, - raw: true, - }, - ); - - const contestRolloverPromises = contestManagersWithEndedContest.map( - async ({ - url, - private_url, - contest_address, - interval, - ended, - neynar_webhook_id, - }) => { - log.info(`ROLLOVER: ${contest_address}`); + { + type: QueryTypes.SELECT, + raw: true, + }, + ); - if (interval === 0 && !ended) { - // preemptively mark as ended so that rollover - // is not attempted again after failure - await models.ContestManager.update( - { - ended: true, - }, - { - where: { - contest_address, - }, - }, - ); - } + const contestRolloverPromises = contestManagersWithEndedContest.map( + async ({ + url, + private_url, + contest_address, + interval, + ended, + neynar_webhook_id, + }) => { + log.info(`ROLLOVER: ${contest_address}`); - await rollOverContest( - getChainNodeUrl({ url, private_url }), + if (interval === 0 && !ended) { + // preemptively mark as ended so that rollover + // is not attempted again after failure + await models.ContestManager.update( + { + ended: true, + }, + { + where: { contest_address, - interval === 0, - ); - - // clean up neynar webhooks when farcaster contest ends - if (neynar_webhook_id) { - try { - const client = new NeynarAPIClient( - config.CONTESTS.NEYNAR_API_KEY!, - ); - await client.deleteWebhook(neynar_webhook_id); - await models.ContestManager.update( - { - neynar_webhook_id: null, - neynar_webhook_secret: null, - }, - { - where: { - contest_address, - }, - }, - ); - } catch (err) { - log.warn( - `failed to delete neynar webhook: ${neynar_webhook_id}`, - ); - } - } + }, }, ); + } - const promiseResults = await Promise.allSettled( - contestRolloverPromises, - ); - - const errors = promiseResults - .filter(({ status }) => status === 'rejected') - .map( - (result) => - (result as PromiseRejectedResult).reason || '', - ); + await rollOverContest( + getChainNodeUrl({ url, private_url }), + contest_address, + interval === 0, + ); - if (errors.length > 0) { - log.error( - `PerformContestRollovers: failed with errors: ${errors.join(', ')}"`, + // clean up neynar webhooks when farcaster contest ends + if (neynar_webhook_id) { + try { + const client = new NeynarAPIClient(config.CONTESTS.NEYNAR_API_KEY!); + await client.deleteWebhook(neynar_webhook_id); + await models.ContestManager.update( + { + neynar_webhook_id: null, + neynar_webhook_secret: null, + }, + { + where: { + contest_address, + }, + }, ); + } catch (err) { + log.warn(`failed to delete neynar webhook: ${neynar_webhook_id}`); } - }, + } }, - }; -} + ); + + const promiseResults = await Promise.allSettled(contestRolloverPromises); + + const errors = promiseResults + .filter(({ status }) => status === 'rejected') + .map( + (result) => + (result as PromiseRejectedResult).reason || '', + ); + + if (errors.length > 0) { + log.error( + `PerformContestRollovers: failed with errors: ${errors.join(', ')}"`, + ); + } +}; diff --git a/libs/model/test/contest/check-contests.spec.ts b/libs/model/test/contest/check-contests.spec.ts index 0fdd2aa3cdc..f01067e61c8 100644 --- a/libs/model/test/contest/check-contests.spec.ts +++ b/libs/model/test/contest/check-contests.spec.ts @@ -168,7 +168,7 @@ describe('Check Contests', () => { ); await handleEvent(ContestWorker(), { - name: EventNames.CheckContests, + name: EventNames.ContestRolloverTimerTicked, payload: {}, }); @@ -188,7 +188,7 @@ describe('Check Contests', () => { ); await handleEvent(ContestWorker(), { - name: EventNames.CheckContests, + name: EventNames.ContestRolloverTimerTicked, payload: {}, }); diff --git a/packages/commonwealth/server/workers/commonwealthConsumer/commonwealthConsumer.ts b/packages/commonwealth/server/workers/commonwealthConsumer/commonwealthConsumer.ts index 1f8eb63faf6..31ce750970c 100644 --- a/packages/commonwealth/server/workers/commonwealthConsumer/commonwealthConsumer.ts +++ b/packages/commonwealth/server/workers/commonwealthConsumer/commonwealthConsumer.ts @@ -145,16 +145,7 @@ function startRolloverLoop() { const loop = async () => { try { await handleEvent(ContestWorker(), { - name: EventNames.CheckContests, - payload: {}, - }); - } catch (err) { - log.error(err); - } - - try { - await handleEvent(ContestWorker(), { - name: EventNames.RolloverContests, + name: EventNames.ContestRolloverTimerTicked, payload: {}, }); } catch (err) { From f1d92e0b39c5cdcbca322f4f6738f513aa2a79c4 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Fri, 22 Nov 2024 09:50:58 -0800 Subject: [PATCH 15/17] fix test --- .../test/contest/contest-worker-policy-lifecycle.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/model/test/contest/contest-worker-policy-lifecycle.spec.ts b/libs/model/test/contest/contest-worker-policy-lifecycle.spec.ts index 9fe68070621..58c3c331af3 100644 --- a/libs/model/test/contest/contest-worker-policy-lifecycle.spec.ts +++ b/libs/model/test/contest/contest-worker-policy-lifecycle.spec.ts @@ -180,7 +180,7 @@ describe('Contest Worker Policy Lifecycle', () => { expect(voteContentStub.called, 'voteContent was not called').to.be.true; await handleEvent(ContestWorker(), { - name: EventNames.RolloverContests, + name: EventNames.ContestRolloverTimerTicked, payload: {}, }); @@ -206,7 +206,7 @@ describe('Contest Worker Policy Lifecycle', () => { ); await handleEvent(ContestWorker(), { - name: EventNames.RolloverContests, + name: EventNames.ContestRolloverTimerTicked, payload: {}, }); From 1237eb85008dcd2ad86462881bfc3d3a5d41d60d Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Fri, 22 Nov 2024 09:54:54 -0800 Subject: [PATCH 16/17] fix schema --- libs/core/src/integration/events.schemas.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/libs/core/src/integration/events.schemas.ts b/libs/core/src/integration/events.schemas.ts index 877012bd847..3f9d2194bf0 100644 --- a/libs/core/src/integration/events.schemas.ts +++ b/libs/core/src/integration/events.schemas.ts @@ -263,6 +263,4 @@ export const FarcasterVoteCreated = FarcasterAction.extend({ contest_address: z.string(), }).describe('When a farcaster action is initiated on a cast reply'); -export const CheckContests = z.object({}); - export const ContestRolloverTimerTicked = z.object({}); From 9fbd55755bd8f2f5b4a58b4206ba99622cc56304 Mon Sep 17 00:00:00 2001 From: Ryan Bennett Date: Mon, 25 Nov 2024 10:56:59 -0800 Subject: [PATCH 17/17] remove feature flag --- .../Contests/ContestsList/ContestCard/ContestCard.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ContestsList/ContestCard/ContestCard.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ContestsList/ContestCard/ContestCard.tsx index af4ce900dbb..a086c84a7ab 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ContestsList/ContestCard/ContestCard.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ContestsList/ContestCard/ContestCard.tsx @@ -173,12 +173,7 @@ const ContestCard = ({ const hasVotes = score.length > 0; const hasLessVotesThanPrizes = (payoutStructure || []).length > score.length; - // TODO remove this flag during the bacakend - // implementation in https://github.com/hicommonwealth/commonwealth/issues/9922 - const showNoUpvotesWarningFlag = false; - const showNoUpvotesWarning = - showNoUpvotesWarningFlag && isActive && isAdmin && isLessThan24HoursLeft && @@ -238,8 +233,8 @@ const ContestCard = ({ !hasVotes ? "The prize amount will be returned to Common and then to admin's wallet if there are no upvotes" : hasLessVotesThanPrizes - ? `You have ${payoutStructure?.length} prizes but only ${score.length} thread upvotes. - Upvote more threads to avoid return of funds. + ? `You have ${payoutStructure?.length} prizes but only ${score.length} thread upvotes. + Upvote more threads to avoid return of funds. The prize amount will be returned to Common and then to admin's wallet if there are no upvotes` : '' }