From 77373c38bbfe96c59f8013fb758b0aeb4a489783 Mon Sep 17 00:00:00 2001 From: lemu Date: Wed, 28 Aug 2024 16:21:23 -0300 Subject: [PATCH] chore: cliff ending soon notification (#1891) * chore: cliff ending soon notification * chore: fetch vestings that ended within 24 hs, add cron job, filter paused or revoked contracts * chore: include bids, lowercase addresses before querying, coauthors fix, vestings query fix * chore: try/catch cliff notifications job --- src/clients/VestingsSubgraph.ts | 118 ++++++++++++------------ src/constants.ts | 6 ++ src/entities/Proposal/jobs.ts | 12 +++ src/entities/Proposal/model.ts | 32 +++++++ src/entities/Proposal/types.ts | 7 ++ src/server.ts | 3 +- src/services/ProposalService.ts | 4 + src/services/VestingService.ts | 7 +- src/services/notification.ts | 153 ++++++++++++++++++++++++-------- src/utils/notifications.ts | 4 + 10 files changed, 249 insertions(+), 97 deletions(-) diff --git a/src/clients/VestingsSubgraph.ts b/src/clients/VestingsSubgraph.ts index 1bdcd6d2f..162aa2479 100644 --- a/src/clients/VestingsSubgraph.ts +++ b/src/clients/VestingsSubgraph.ts @@ -1,12 +1,43 @@ import fetch from 'isomorphic-fetch' import { VESTINGS_QUERY_ENDPOINT } from '../entities/Snapshot/constants' +import Time from '../utils/date/Time' import { SubgraphVesting } from './VestingSubgraphTypes' import { trimLastForwardSlash } from './utils' const OLDEST_INDEXED_BLOCK = 20463272 +const VESTING_FIELDS = `id + version + duration + cliff + beneficiary + revoked + revocable + released + start + periodDuration + vestedPerPeriod + paused + pausable + stop + linear + token + owner + total + revokeTimestamp + releaseLogs{ + id + timestamp + amount + } + pausedLogs{ + id + timestamp + eventType + }` + export class VestingsSubgraph { static Cache = new Map() private readonly queryEndpoint: string @@ -41,39 +72,10 @@ export class VestingsSubgraph { const query = ` query getVesting($address: String!) { vestings(where: { id: $address }){ - id - version - duration - cliff - beneficiary - revoked - revocable - released - start - periodDuration - vestedPerPeriod - paused - pausable - stop - linear - token - owner - total - revokeTimestamp - releaseLogs{ - id - timestamp - amount - } - pausedLogs{ - id - timestamp - eventType - } + ${VESTING_FIELDS} } } ` - const variables = { address: address.toLowerCase() } const response = await fetch(this.queryEndpoint, { method: 'post', @@ -97,35 +99,7 @@ export class VestingsSubgraph { const query = ` query getVestings(${addressesParam}) { vestings(${addressesQuery}){ - id - version - duration - cliff - beneficiary - revoked - revocable - released - start - periodDuration - vestedPerPeriod - paused - pausable - stop - linear - token - owner - total - revokeTimestamp - releaseLogs{ - id - timestamp - amount - } - pausedLogs{ - id - timestamp - eventType - } + ${VESTING_FIELDS} } } ` @@ -144,4 +118,30 @@ export class VestingsSubgraph { const body = await response.json() return body?.data?.vestings || [] } + + async getVestingsWithRecentlyEndedCliffs(): Promise { + const currentTimestamp = Time().getTime() + const aDayAgoTimestamp = Time().subtract(1, 'day').getTime() + const query = ` + query getVestings($currentTimestamp: Int!, $aDayAgoTimestamp: Int!) { + vestings(where: { cliff_gt: $aDayAgoTimestamp, cliff_lt: $currentTimestamp, + revoked:false, paused:false}) { + ${VESTING_FIELDS} + } + } + ` + + const variables = { currentTimestamp, aDayAgoTimestamp } + const response = await fetch(this.queryEndpoint, { + method: 'post', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query, + variables, + }), + }) + + const body = await response.json() + return body?.data?.vestings || [] + } } diff --git a/src/constants.ts b/src/constants.ts index 32e135ff9..a419536be 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -19,6 +19,10 @@ function getBooleanStringVar(variableName: string, defaultValue: boolean) { return defaultValue } +export function getVestingContractUrl(address: string) { + return VESTING_DASHBOARD_URL.replace('%23', '#').concat(address.toLowerCase()) +} + export const GOVERNANCE_URL = process.env.GATSBY_GOVERNANCE_URL || 'https://decentraland.zone/governance' export const GOVERNANCE_API = process.env.GATSBY_GOVERNANCE_API || '' export const FORUM_URL = process.env.GATSBY_DISCOURSE_API || '' @@ -54,3 +58,5 @@ export const DCL_META_IMAGE_URL = 'https://decentraland.org/images/decentraland. export const JOIN_DISCORD_URL = 'https://dcl.gg/discord' export const BLOCKNATIVE_API_KEY = process.env.BLOCKNATIVE_API_KEY || '' export const REASON_THRESHOLD = Number(process.env.GATSBY_REASON_THRESHOLD) + +export const VESTING_DASHBOARD_URL = 'https://decentraland.org/vesting/%23/' diff --git a/src/entities/Proposal/jobs.ts b/src/entities/Proposal/jobs.ts index 373df5efc..1549310bf 100644 --- a/src/entities/Proposal/jobs.ts +++ b/src/entities/Proposal/jobs.ts @@ -9,6 +9,7 @@ import { DiscourseService } from '../../services/DiscourseService' import { ErrorService } from '../../services/ErrorService' import { ProjectService } from '../../services/ProjectService' import { ProposalService } from '../../services/ProposalService' +import { VestingService } from '../../services/VestingService' import { DiscordService } from '../../services/discord' import { EventsService } from '../../services/events' import { NotificationService } from '../../services/notification' @@ -251,3 +252,14 @@ async function updateProposalsAndBudgets(proposalsWithOutcome: ProposalWithOutco client.release() } } + +export async function notifyCliffEndingSoon() { + try { + const vestings = await VestingService.getVestingsWithRecentlyEndedCliffs() + const vestingAddresses = vestings.map((vesting) => vesting.address) + const proposalContributors = await ProposalService.findContributorsForProposalsByVestings(vestingAddresses) + await NotificationService.cliffEnded(proposalContributors) + } catch (error) { + ErrorService.report('Error notifying cliff ending soon', { error, category: ErrorCategory.Job }) + } +} diff --git a/src/entities/Proposal/model.ts b/src/entities/Proposal/model.ts index e768b68c5..b55ff2c76 100644 --- a/src/entities/Proposal/model.ts +++ b/src/entities/Proposal/model.ts @@ -30,6 +30,7 @@ import { PriorityProposal, PriorityProposalType, ProposalAttributes, + ProposalContributors, ProposalListFilter, ProposalStatus, ProposalType, @@ -635,4 +636,35 @@ export default class ProposalModel extends Model { return await this.namedQuery('get_priority_proposals', query) } + + static async findContributorsForProposalsByVestings(vestingAddresses: string[]): Promise { + const query = SQL` + SELECT + p.id, + p.title, + COALESCE(array_agg(co.address) FILTER (WHERE co.address IS NOT NULL), '{}') AS coauthors, + p.vesting_addresses, + p.user, + p.configuration + FROM ${table(ProposalModel)} p + LEFT JOIN ${table(CoauthorModel)} co ON p.id = co.proposal_id + AND co.status = ${CoauthorStatus.APPROVED} + WHERE + p.type IN (${ProposalType.Grant}, ${ProposalType.Bid}) + AND + EXISTS ( + SELECT 1 + FROM unnest(p.vesting_addresses) AS vesting_address + WHERE LOWER(vesting_address) = ANY(${vestingAddresses}) + ) + GROUP BY + p.id, + p.title, + p.vesting_addresses, + p.user + ORDER BY p.created_at DESC; + ` + + return await this.namedQuery('get_authors_and_coauthors_for_vestings', query) + } } diff --git a/src/entities/Proposal/types.ts b/src/entities/Proposal/types.ts index e88edd59c..801da37d0 100644 --- a/src/entities/Proposal/types.ts +++ b/src/entities/Proposal/types.ts @@ -828,3 +828,10 @@ export type PriorityProposal = Pick< } export type LinkedProposal = Pick + +export type ProposalContributors = Pick< + ProposalAttributes, + 'id' | 'title' | 'user' | 'vesting_addresses' | 'configuration' +> & { + coauthors?: string[] +} diff --git a/src/server.ts b/src/server.ts index a5af21c09..e909af98c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -16,7 +16,7 @@ import swaggerUi from 'swagger-ui-express' import YAML from 'yaml' import { updateGovernanceBudgets } from './entities/Budget/jobs' -import { activateProposals, finishProposal, publishBids } from './entities/Proposal/jobs' +import { activateProposals, finishProposal, notifyCliffEndingSoon, publishBids } from './entities/Proposal/jobs' import { giveAndRevokeLandOwnerBadges, giveTopVoterBadges, runQueuedAirdropJobs } from './jobs/BadgeAirdrop' import { pingSnapshot } from './jobs/PingSnapshot' import { withLock } from './jobs/jobLocks' @@ -50,6 +50,7 @@ jobs.cron('@eachMinute', activateProposals) jobs.cron('@each5Minute', withLock('publishBids', publishBids)) jobs.cron('@each10Second', pingSnapshot) jobs.cron('30 0 * * *', updateGovernanceBudgets) // Runs at 00:30 daily +jobs.cron('35 0 * * *', notifyCliffEndingSoon) // Runs at 00:35 daily jobs.cron('30 1 * * *', runQueuedAirdropJobs) // Runs at 01:30 daily jobs.cron('30 2 * * *', giveAndRevokeLandOwnerBadges) // Runs at 02:30 daily jobs.cron('30 3 1 * *', giveTopVoterBadges) // Runs at 03:30 on the first day of the month diff --git a/src/services/ProposalService.ts b/src/services/ProposalService.ts index a11b7a9de..01cb70b63 100644 --- a/src/services/ProposalService.ts +++ b/src/services/ProposalService.ts @@ -335,4 +335,8 @@ export class ProposalService { } return update } + + static async findContributorsForProposalsByVestings(vestingAddresses: string[]) { + return await ProposalModel.findContributorsForProposalsByVestings(vestingAddresses) + } } diff --git a/src/services/VestingService.ts b/src/services/VestingService.ts index 8e107b070..b2adb9757 100644 --- a/src/services/VestingService.ts +++ b/src/services/VestingService.ts @@ -40,6 +40,11 @@ export class VestingService { return sortedVestings } + static async getVestingsWithRecentlyEndedCliffs(): Promise { + const vestingsData = await VestingsSubgraph.get().getVestingsWithRecentlyEndedCliffs() + return vestingsData.map(this.parseSubgraphVesting) + } + static async getVestingWithLogs( vestingAddress: string | null | undefined, proposalId?: string @@ -142,7 +147,7 @@ export class VestingService { const token = getTokenSymbolFromAddress(vestingData.token) return { - address: vestingData.id, + address: vestingData.id.toLowerCase(), cliff: toISOString(cliffEnd), vestedPerPeriod: vestingData.vestedPerPeriod.map(Number), ...getVestingDates(contractStart, contractEndsTimestamp), diff --git a/src/services/notification.ts b/src/services/notification.ts index f7f8c51c5..b90fc8d42 100644 --- a/src/services/notification.ts +++ b/src/services/notification.ts @@ -2,15 +2,19 @@ import { ChainId } from '@dcl/schemas/dist/dapps/chain-id' import { ethers } from 'ethers' import { SnapshotSubgraph } from '../clients/SnapshotSubgraph' -import { DCL_NOTIFICATIONS_SERVICE_ENABLED, NOTIFICATIONS_SERVICE_ENABLED, PUSH_CHANNEL_ID } from '../constants' +import { + DCL_NOTIFICATIONS_SERVICE_ENABLED, + NOTIFICATIONS_SERVICE_ENABLED, + PUSH_CHANNEL_ID, + getVestingContractUrl, +} from '../constants' import ProposalModel from '../entities/Proposal/model' import { ProposalWithOutcome } from '../entities/Proposal/outcome' -import { ProposalAttributes, ProposalStatus, ProposalType } from '../entities/Proposal/types' +import { ProposalAttributes, ProposalContributors, ProposalStatus, ProposalType } from '../entities/Proposal/types' import { proposalUrl } from '../entities/Proposal/utils' import { isSameAddress } from '../entities/Snapshot/utils' import { getUpdateUrl } from '../entities/Updates/utils' import { inBackground } from '../helpers' -import { ErrorService } from '../services/ErrorService' import { ProjectUpdateCommentedEvent, ProposalCommentedEvent } from '../shared/types/events' import { DclNotification, Notification, NotificationCustomType, Recipient } from '../shared/types/notifications' import { ErrorCategory } from '../utils/errorCategories' @@ -19,6 +23,7 @@ import logger from '../utils/logger' import { NotificationType, Notifications, getCaipAddress, getPushNotificationsEnv } from '../utils/notifications' import { areValidAddresses } from '../utils/validations' +import { ErrorService } from './ErrorService' import { ProposalService } from './ProposalService' import { SnapshotService } from './SnapshotService' import { CoauthorService } from './coauthor' @@ -341,11 +346,12 @@ export class NotificationService { const title = Notifications.TenderPassed.title(proposal) const body = Notifications.TenderPassed.body + const url = proposalUrl(proposal.id) DiscordService.sendDirectMessages(addresses, { title, action: body, - url: proposalUrl(proposal.id), + url, fields: [], }) @@ -356,9 +362,9 @@ export class NotificationService { metadata: { proposalId: proposal.id, proposalTitle: proposal.title, - title: Notifications.TenderPassed.title(proposal), - description: Notifications.TenderPassed.body, - link: proposalUrl(proposal.id), + title: title, + description: body, + link: url, }, timestamp: Date.now(), })) @@ -368,7 +374,7 @@ export class NotificationService { title, body, recipient: addresses, - url: proposalUrl(proposal.id), + url, customType: NotificationCustomType.TenderPassed, }), this.sendDCLNotifications(dclNotifications), @@ -388,11 +394,12 @@ export class NotificationService { try { const title = Notifications.ProposalAuthoredFinished.title(proposal) const body = Notifications.ProposalAuthoredFinished.body + const url = proposalUrl(proposal.id) DiscordService.sendDirectMessages(addresses, { title, action: body, - url: proposalUrl(proposal.id), + url: url, fields: [], }) @@ -403,9 +410,9 @@ export class NotificationService { metadata: { proposalId: proposal.id, proposalTitle: proposal.title, - title: Notifications.ProposalAuthoredFinished.title(proposal), - description: Notifications.ProposalAuthoredFinished.body, - link: proposalUrl(proposal.id), + title: title, + description: body, + link: url, }, timestamp: Date.now(), })) @@ -415,7 +422,7 @@ export class NotificationService { title, body, recipient: addresses, - url: proposalUrl(proposal.id), + url, customType: NotificationCustomType.Proposal, }), this.sendDCLNotifications(dclNotifications), @@ -439,11 +446,12 @@ export class NotificationService { const title = Notifications.ProposalVotedFinished.title(proposal) const body = Notifications.ProposalVotedFinished.body + const url = proposalUrl(proposal.id) DiscordService.sendDirectMessages(addresses, { title, action: body, - url: proposalUrl(proposal.id), + url, fields: [], }) @@ -454,9 +462,9 @@ export class NotificationService { metadata: { proposalId: proposal.id, proposalTitle: proposal.title, - title: Notifications.ProposalVotedFinished.title(proposal), - description: Notifications.ProposalVotedFinished.body, - link: proposalUrl(proposal.id), + title, + description: body, + link: url, }, timestamp: Date.now(), })) @@ -466,7 +474,7 @@ export class NotificationService { title, body, recipient: addresses, - url: proposalUrl(proposal.id), + url, customType: NotificationCustomType.Proposal, }), this.sendDCLNotifications(dclNotifications), @@ -517,10 +525,14 @@ export class NotificationService { const proposal = await ProposalModel.getProposal(proposalId) const addresses = await this.getAuthorAndCoauthors(proposal) + const title = Notifications.ProposalCommented.title(proposal) + const body = Notifications.ProposalCommented.body + const url = proposalUrl(proposal.id) + DiscordService.sendDirectMessages(addresses, { - title: Notifications.ProposalCommented.title(proposal), - action: Notifications.ProposalCommented.body, - url: proposalUrl(proposal.id), + title: title, + action: body, + url, fields: [], }) @@ -531,19 +543,19 @@ export class NotificationService { metadata: { proposalId: proposal.id, proposalTitle: proposal.title, - title: Notifications.ProposalCommented.title(proposal), - description: Notifications.ProposalCommented.body, - link: proposalUrl(proposal.id), + title, + description: body, + link: url, }, timestamp: Date.now(), })) await Promise.all([ this.sendPushNotification({ - title: Notifications.ProposalCommented.title(proposal), - body: Notifications.ProposalCommented.body, + title, + body, recipient: addresses, - url: proposalUrl(proposal.id), + url: url, customType: NotificationCustomType.ProposalComment, }), this.sendDCLNotifications(dclNotifications), @@ -566,11 +578,14 @@ export class NotificationService { try { const proposal = await ProposalModel.getProposal(proposalId) const addresses = await this.getAuthorAndCoauthors(proposal) + const title = Notifications.ProjectUpdateCommented.title(proposal) + const body = Notifications.ProjectUpdateCommented.body + const updateUrl = getUpdateUrl(updateId, proposal.id) DiscordService.sendDirectMessages(addresses, { - title: Notifications.ProjectUpdateCommented.title(proposal), - action: Notifications.ProjectUpdateCommented.body, - url: getUpdateUrl(updateId, proposal.id), + title: title, + action: body, + url: updateUrl, fields: [], }) @@ -581,19 +596,19 @@ export class NotificationService { metadata: { proposalId: proposal.id, proposalTitle: proposal.title, - title: Notifications.ProjectUpdateCommented.title(proposal), - description: Notifications.ProjectUpdateCommented.body, - link: getUpdateUrl(updateId, proposal.id), + title, + description: body, + link: updateUrl, }, timestamp: Date.now(), })) await Promise.all([ this.sendPushNotification({ - title: Notifications.ProjectUpdateCommented.title(proposal), - body: Notifications.ProjectUpdateCommented.body, + title, + body, recipient: addresses, - url: getUpdateUrl(updateId, proposal.id), + url: updateUrl, customType: NotificationCustomType.ProjectUpdateComment, }), this.sendDCLNotifications(dclNotifications), @@ -733,4 +748,70 @@ export class NotificationService { } }) } + + static async cliffEnded(proposalsWithContributors: ProposalContributors[]) { + inBackground(async () => { + proposalsWithContributors.map(async (proposalWithContributors) => { + try { + const { id: proposal_id } = proposalWithContributors + const addresses = getUniqueContributors(proposalWithContributors) + + const title = Notifications.CliffEnded.title(proposalWithContributors.title) + const body = Notifications.CliffEnded.body + const latestVestingAddress = + proposalWithContributors.vesting_addresses[proposalWithContributors.vesting_addresses.length - 1] + const url = getVestingContractUrl(latestVestingAddress) + + DiscordService.sendDirectMessages(addresses, { + title, + action: body, + url, + fields: [], + }) + + const dclNotifications = addresses.map((address) => ({ + type: 'governance_cliff_ended', + address, + eventKey: proposal_id, + metadata: { + proposalId: proposal_id, + proposalTitle: title, + title: title, + description: body, + link: url, + }, + timestamp: Date.now(), + })) + + await Promise.all([ + this.sendPushNotification({ + title, + body, + recipient: addresses, + url, + customType: NotificationCustomType.Proposal, + }), + this.sendDCLNotifications(dclNotifications), + ]) + } catch (error) { + ErrorService.report('Error sending cliff ended notification to proposal contributors', { + error: `${error}`, + category: ErrorCategory.Notifications, + proposalsWithContributors, + }) + } + }) + }) + } +} + +function getUniqueContributors(proposalContributors: ProposalContributors) { + const { user, coauthors, configuration } = proposalContributors + const addressesSet = new Set() + addressesSet.add(user) + if (!!coauthors && coauthors.length > 0) { + coauthors.forEach((coAuthor) => addressesSet.add(coAuthor)) + } + if (configuration.beneficiary) addressesSet.add(configuration.beneficiary) + return Array.from(addressesSet) } diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts index 67bc74317..df52b293d 100644 --- a/src/utils/notifications.ts +++ b/src/utils/notifications.ts @@ -68,4 +68,8 @@ export const Notifications = { title: (proposal: ProposalAttributes) => `Your delegate voted on the proposal "${proposal.title}"`, body: 'See if their vote is aligned with your vision. You can always override their decision by voting on your own.', }, + CliffEnded: { + title: (title: string) => `Funds are ready to vest for your project "${title}"`, + body: 'The cliff period to vest funds has ended. Check the contract status now!', + }, }