Skip to content

Commit

Permalink
Apply contest rate limits (#8475)
Browse files Browse the repository at this point in the history
* apply contest limits

* add contest check on thread creation + return contests with topics

* fix config

* fix topics contest data + filter to content

* fix query

* update schema

* fix typing

* fix types

* fix test

* Contest - rate limiting UI (#8505)

* 8474 affordance for admin

* disable creating topic

* disable creating topic if user has dust eth value

* revert value

---------

Co-authored-by: Ryan Bennett <ryan@common.xyz>

* move ETH check to command + fix UI limit check

* fix topics refresh on thread create page

* lint

---------

Co-authored-by: Marcin Maslanka <maslankam92@gmail.com>
  • Loading branch information
rbennettcw and masvelio authored Jul 18, 2024
1 parent f9ad90e commit c6eaddc
Show file tree
Hide file tree
Showing 22 changed files with 470 additions and 83 deletions.
2 changes: 1 addition & 1 deletion libs/core/src/framework/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const query = async <Input extends ZodSchema, Output extends ZodSchema>(
{ input, auth, body }: QueryMetadata<Input, Output>,
{ actor, payload }: QueryContext<Input>,
validate = true,
): Promise<Partial<z.infer<Output>> | undefined> => {
): Promise<z.infer<Output> | undefined> => {
try {
const context: QueryContext<Input> = {
actor,
Expand Down
2 changes: 1 addition & 1 deletion libs/core/src/framework/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export type CommandHandler<
*/
export type QueryHandler<Input extends ZodSchema, Output extends ZodSchema> = (
context: QueryContext<Input>,
) => Promise<Partial<z.infer<Output>> | undefined>;
) => Promise<z.infer<Output> | undefined>;

/**
* Event handler
Expand Down
11 changes: 11 additions & 0 deletions libs/model/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const {
TBC_BALANCE_TTL_SECONDS,
ALLOWED_EVENTS,
INIT_TEST_DB,
MAX_USER_POSTS_PER_CONTEST,
JWT_SECRET,
} = process.env;

Expand Down Expand Up @@ -46,6 +47,12 @@ export const config = configure(
OUTBOX: {
ALLOWED_EVENTS: ALLOWED_EVENTS ? ALLOWED_EVENTS.split(',') : [],
},
CONTESTS: {
MIN_USER_ETH: 0.0005,
MAX_USER_POSTS_PER_CONTEST: MAX_USER_POSTS_PER_CONTEST
? parseInt(MAX_USER_POSTS_PER_CONTEST, 10)
: 2,
},
AUTH: {
JWT_SECRET: JWT_SECRET || DEFAULTS.JWT_SECRET,
SESSION_EXPIRY_MILLIS: 30 * 24 * 60 * 60 * 1000,
Expand All @@ -68,6 +75,10 @@ export const config = configure(
OUTBOX: z.object({
ALLOWED_EVENTS: z.array(z.string()),
}),
CONTESTS: z.object({
MIN_USER_ETH: z.number(),
MAX_USER_POSTS_PER_CONTEST: z.number().int(),
}),
AUTH: z
.object({
JWT_SECRET: z.string(),
Expand Down
70 changes: 70 additions & 0 deletions libs/model/src/contest/GetActiveContestManagers.query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Query } from '@hicommonwealth/core';
import * as schemas from '@hicommonwealth/schemas';
import { QueryTypes } from 'sequelize';
import z from 'zod';
import { models } from '../database';

// GetActiveContestManagers returns all contest managers which are active
// in the specified community and topic, along with the actions within
// each manager's most recent contest
export function GetActiveContestManagers(): Query<
typeof schemas.GetActiveContestManagers
> {
return {
...schemas.GetActiveContestManagers,
auth: [],
body: async ({ payload }) => {
const results = await models.sequelize.query<{
eth_chain_id: number;
url: string;
contest_address: string;
max_contest_id: number;
actions: Array<z.infer<typeof schemas.ContestAction>>;
}>(
`
SELECT
cn.eth_chain_id,
COALESCE(cn.private_url, cn.url) as url,
cm.contest_address,
co.max_contest_id,
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
JOIN "ContestManagers" cm ON cm.community_id = c.id
JOIN "ContestTopics" ct ON cm.contest_address = ct.contest_address
JOIN (
SELECT contest_address,
MAX(contest_id) AS max_contest_id,
MAX(start_time) as start_time,
MAX(end_time) as end_time
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
)
WHERE ct.topic_id = :topic_id
AND cm.community_id = :community_id
AND cm.cancelled = false
AND (
cm.interval = 0 AND NOW() < co.end_time
OR
cm.interval > 0
)
GROUP BY cn.eth_chain_id, cn.private_url, cn.url, cm.contest_address, co.max_contest_id
`,
{
type: QueryTypes.SELECT,
replacements: {
topic_id: payload.topic_id!,
community_id: payload.community_id,
},
},
);

return results;
},
};
}
1 change: 1 addition & 0 deletions libs/model/src/contest/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './CancelContestManagerMetadata.command';
export * from './Contests.projection';
export * from './CreateContestManagerMetadata.command';
export * from './GetActiveContestManagers.query';
export * from './GetAllContests.query';
export * from './GetContestLog.query';
export * from './PerformContestRollovers.command';
Expand Down
65 changes: 29 additions & 36 deletions libs/model/src/policies/ContestWorker.policy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { events, logger, Policy } from '@hicommonwealth/core';
import { Actor, events, logger, Policy } from '@hicommonwealth/core';
import { QueryTypes } from 'sequelize';
import { fileURLToPath } from 'url';
import { config, Contest } from '..';
import { models } from '../database';
import { contestHelper } from '../services/commonProtocol';
import { buildThreadContentUrl } from '../utils';
Expand Down Expand Up @@ -32,50 +33,42 @@ export function ContestWorker(): Policy<typeof inputs> {
payload.id!,
);

const activeContestManagers = await models.sequelize.query<{
contest_address: string;
url: string;
private_url: string;
}>(
`
SELECT COALESCE(cn.private_url, cn.url) as url, cm.contest_address
FROM "Communities" c
JOIN "ChainNodes" cn ON c.chain_node_id = cn.id
JOIN "ContestManagers" cm ON cm.community_id = c.id
JOIN "ContestTopics" ct ON cm.contest_address = ct.contest_address
JOIN (
SELECT contest_address, MAX(contest_id) AS max_contest_id, MAX(end_time) as end_time
FROM "Contests"
GROUP BY contest_address
) co ON cm.contest_address = co.contest_address
WHERE ct.topic_id = :topic_id
AND cm.community_id = :community_id
AND cm.cancelled = false
AND (
cm.interval = 0 AND NOW() < co.end_time
OR
cm.interval > 0
)
`,
{
type: QueryTypes.SELECT,
replacements: {
topic_id: payload.topic_id!,
const activeContestManagers =
await Contest.GetActiveContestManagers().body({
actor: {} as Actor,
payload: {
community_id: payload.community_id,
topic_id: payload.topic_id,
},
},
);

});
if (!activeContestManagers?.length) {
log.warn('ThreadCreated: no matching contest managers found');
return;
}

const chainNodeUrl = activeContestManagers[0]!.url;

const addressesToProcess = activeContestManagers.map(
(c) => c.contest_address,
);
const addressesToProcess = activeContestManagers
.filter((c) => {
// only process contest managers for which
// the user has not exceeded the post limit
// on the latest contest
const userPostsInContest = c.actions.filter(
(action) =>
action.actor_address === userAddress &&
action.action === 'added',
);
const quotaReached =
userPostsInContest.length >=
config.CONTESTS.MAX_USER_POSTS_PER_CONTEST;
if (quotaReached) {
log.warn(
`ThreadCreated: user reached post limit for contest ${c.contest_address} (ID ${c.max_contest_id})`,
);
}
return !quotaReached;
})
.map((c) => c.contest_address);

log.debug(
`ThreadCreated: addresses to process: ${JSON.stringify(
Expand Down
18 changes: 17 additions & 1 deletion libs/schemas/src/queries/contests.schemas.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { ContestManager } from '../entities';
import { Contest } from '../projections';
import { Contest, ContestAction } from '../projections';
import { PG_INT, zDate } from '../utils';

export const ContestResults = ContestManager.extend({
Expand All @@ -25,6 +25,22 @@ export const GetAllContests = {
output: z.array(ContestResults),
};

export const GetActiveContestManagers = {
input: z.object({
community_id: z.string(),
topic_id: z.number(),
}),
output: z.array(
z.object({
eth_chain_id: z.number().int(),
url: z.string(),
contest_address: z.string(),
max_contest_id: z.number(),
actions: z.array(ContestAction),
}),
),
};

export const ContestLogEntry = z.object({
event_name: z.string(),
event_payload: z.object({}),
Expand Down
2 changes: 1 addition & 1 deletion libs/schemas/src/queries/subscription.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {

export const GetSubscriptionPreferences = {
input: z.object({}),
output: SubscriptionPreference,
output: z.union([SubscriptionPreference, z.object({})]),
};

export const GetCommunityAlerts = {
Expand Down
14 changes: 14 additions & 0 deletions packages/commonwealth/client/scripts/models/Topic.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
import * as schemas from '@hicommonwealth/schemas';
import { z } from 'zod';

const ActiveContestManagers = z.object({
content: z.array(schemas.ContestAction),
contest_manager: schemas.ContestManager,
});

export type TopicAttributes = {
name: string;
id: number;
Expand All @@ -10,6 +18,7 @@ export type TopicAttributes = {
default_offchain_template?: string;
total_threads: number;
channel_id?: string;
active_contest_managers: Array<z.infer<typeof ActiveContestManagers>>;
};

class Topic {
Expand All @@ -24,6 +33,9 @@ class Topic {
public order?: number;
public readonly defaultOffchainTemplate?: string;
public totalThreads?: number;
public readonly activeContestManagers: Array<
z.infer<typeof ActiveContestManagers>
>;

constructor({
name,
Expand All @@ -37,6 +49,7 @@ class Topic {
default_offchain_template,
total_threads,
channel_id,
active_contest_managers,
}: TopicAttributes) {
this.name = name;
this.id = id;
Expand All @@ -50,6 +63,7 @@ class Topic {
this.defaultOffchainTemplate = default_offchain_template;
this.totalThreads = total_threads || 0;
this.channelId = channel_id;
this.activeContestManagers = active_contest_managers || [];
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,19 @@ const TOPICS_STALE_TIME = 30 * 1_000; // 30 s
interface FetchTopicsProps {
communityId: string;
apiEnabled?: boolean;
includeContestData?: boolean;
}

const fetchTopics = async ({
communityId,
includeContestData = false,
}: FetchTopicsProps): Promise<Topic[]> => {
const response = await axios.get(
`${app.serverUrl()}${ApiEndpoints.BULK_TOPICS}`,
{
params: {
community_id: communityId || app.activeChainId(),
with_contest_managers: includeContestData,
},
},
);
Expand All @@ -29,10 +32,11 @@ const fetchTopics = async ({
const useFetchTopicsQuery = ({
communityId,
apiEnabled = true,
includeContestData,
}: FetchTopicsProps) => {
return useQuery({
queryKey: [ApiEndpoints.BULK_TOPICS, communityId],
queryFn: () => fetchTopics({ communityId }),
queryKey: [ApiEndpoints.BULK_TOPICS, communityId, includeContestData],
queryFn: () => fetchTopics({ communityId, includeContestData }),
staleTime: TOPICS_STALE_TIME,
enabled: apiEnabled,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const ContestThreadBanner = ({
return (
<CWBanner
className="ContestThreadBanner"
title="This topic has an ongoing contest. Submit thread to contest?"
title="This topic has an ongoing contest(s). Submit thread to contest?"
body="Once a post is submitted it cannot be edited.
The post with the most upvotes will win the contest prize."
type="info"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
@import '../../../../../styles/shared';

.ContestTopicBanner {
.body {
ul {
list-style: initial;
margin-left: 32px;
line-height: 32px;
margin-bottom: 8px;

li {
.Text.disabled {
color: $neutral-400;
}
}
}
}
}
Loading

0 comments on commit c6eaddc

Please sign in to comment.