From a1dc8e12dcb043f82f81db95848090071b14a5b7 Mon Sep 17 00:00:00 2001 From: Andy Espagnolo Date: Tue, 13 Aug 2024 12:58:49 -0300 Subject: [PATCH] feat: add whale vote and voted on behalf notifications (#1889) * feat: add whale vote and voted on behalf notifications * fix: logs texts * feat: move whale vote threshold to env var and fix address check --- .env.example | 2 +- src/clients/SnapshotSubgraph.ts | 5 +- src/entities/Snapshot/queries.ts | 3 +- src/entities/Snapshot/utils.ts | 13 +-- src/services/events.ts | 1 + src/services/notification.ts | 128 ++++++++++++++++++++++++++++++ src/shared/types/notifications.ts | 2 + src/utils/notifications.ts | 8 ++ strategy.json | 104 ++++++++++++++++++++++++ 9 files changed, 250 insertions(+), 16 deletions(-) create mode 100644 strategy.json diff --git a/.env.example b/.env.example index 0cde4d87f..23d0d4ffc 100644 --- a/.env.example +++ b/.env.example @@ -28,7 +28,6 @@ GATSBY_GOVERNANCE_API=https://localhost:8000/api # Snapshot integration GATSBY_SNAPSHOT_API=https://testnet.hub.snapshot.org/ GATSBY_SNAPSHOT_URL=https://testnet.snapshot.org/ -GATSBY_SNAPSHOT_QUERY_ENDPOINT=https://api.thegraph.com/subgraphs/name/snapshot-labs/snapshot GATSBY_SNAPSHOT_SPACE=daotest.dcl.eth GATSBY_SNAPSHOT_DURATION=600 GATSBY_SNAPSHOT_ADDRESS= @@ -109,6 +108,7 @@ NOTIFICATIONS_SERVICE_ENABLED=false PUSH_API_URL=https://backend-staging.epns.io PUSH_CHANNEL_OWNER_PK= GATSBY_PUSH_CHANNEL_ID= +WHALE_VOTE_THRESHOLD= # New Decentraland Notifications Service DCL_NOTIFICATIONS_SERVICE_ENABLED=false diff --git a/src/clients/SnapshotSubgraph.ts b/src/clients/SnapshotSubgraph.ts index 988efab39..88a1d46e9 100644 --- a/src/clients/SnapshotSubgraph.ts +++ b/src/clients/SnapshotSubgraph.ts @@ -1,7 +1,7 @@ import fetch from 'isomorphic-fetch' import { SNAPSHOT_QUERY_ENDPOINT } from '../entities/Snapshot/constants' -import { PICKED_BY_QUERY } from '../entities/Snapshot/queries' +import { PICKED_BY_QUERY, getDelegatedQuery } from '../entities/Snapshot/queries' import { Delegation } from './SnapshotTypes' import { inBatches, trimLastForwardSlash } from './utils' @@ -48,7 +48,6 @@ export class SnapshotSubgraph { async getDelegates( key: 'delegatedTo' | 'delegatedFrom', - query: string, variables: { address: string; space: string; blockNumber?: string | number } ) { const delegations: Delegation[] = await inBatches( @@ -57,7 +56,7 @@ export class SnapshotSubgraph { method: 'post', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - query, + query: getDelegatedQuery(key, variables.blockNumber), variables: { ...vars, skip, first }, }), }) diff --git a/src/entities/Snapshot/queries.ts b/src/entities/Snapshot/queries.ts index 3d93af491..c7ff138c3 100644 --- a/src/entities/Snapshot/queries.ts +++ b/src/entities/Snapshot/queries.ts @@ -10,7 +10,8 @@ const getDelegationType = (key: 'delegatedTo' | 'delegatedFrom') => { export const getDelegatedQuery = (key: 'delegatedTo' | 'delegatedFrom', blockNumber?: BlockNumber) => ` query ($space: String!, $address: String!, $first: Int!, $skip: Int!, $blockNumber: Int) { - ${key}: delegations(${getBlockNumberFilter(blockNumber)} + ${key}: delegations( + ${getBlockNumberFilter(blockNumber)} where: { space_in: ["", $space], ${getDelegationType(key)}: $address }, first: $first, skip: $skip, orderBy: timestamp, orderDirection: desc) { delegator diff --git a/src/entities/Snapshot/utils.ts b/src/entities/Snapshot/utils.ts index 9d7ec9325..8c941177c 100644 --- a/src/entities/Snapshot/utils.ts +++ b/src/entities/Snapshot/utils.ts @@ -12,7 +12,6 @@ import { import logger from '../../utils/logger' import { SNAPSHOT_SPACE } from './constants' -import { getDelegatedQuery } from './queries' export type Match = { proposal_id: string @@ -165,16 +164,8 @@ export async function getDelegations( } const variables = getDelegatesVariables(address, blockNumber) try { - const delegatedTo = await SnapshotSubgraph.get().getDelegates( - 'delegatedTo', - getDelegatedQuery('delegatedTo', blockNumber), - variables - ) - const delegatedFrom = await SnapshotSubgraph.get().getDelegates( - 'delegatedFrom', - getDelegatedQuery('delegatedFrom', blockNumber), - variables - ) + const delegatedTo = await SnapshotSubgraph.get().getDelegates('delegatedTo', variables) + const delegatedFrom = await SnapshotSubgraph.get().getDelegates('delegatedFrom', variables) if (!delegatedTo && !delegatedFrom) { return EMPTY_DELEGATION diff --git a/src/services/events.ts b/src/services/events.ts index 0f8dfe15b..2fb5a43a6 100644 --- a/src/services/events.ts +++ b/src/services/events.ts @@ -141,6 +141,7 @@ export class EventsService { created_at: new Date(), } await EventModel.create(votedEvent) + NotificationService.newVote(proposal_id, address) } catch (error) { this.reportEventError(error as Error, EventType.Voted, { address, proposal_id, proposal_title, choice }) } diff --git a/src/services/notification.ts b/src/services/notification.ts index 19f6bd093..f7f8c51c5 100644 --- a/src/services/notification.ts +++ b/src/services/notification.ts @@ -1,11 +1,13 @@ 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 ProposalModel from '../entities/Proposal/model' import { ProposalWithOutcome } from '../entities/Proposal/outcome' import { ProposalAttributes, 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' @@ -17,6 +19,8 @@ import logger from '../utils/logger' import { NotificationType, Notifications, getCaipAddress, getPushNotificationsEnv } from '../utils/notifications' import { areValidAddresses } from '../utils/validations' +import { ProposalService } from './ProposalService' +import { SnapshotService } from './SnapshotService' import { CoauthorService } from './coauthor' import { DiscordService } from './discord' import { VoteService } from './vote' @@ -605,4 +609,128 @@ export class NotificationService { } }) } + + static async newVote(proposalId: ProposalAttributes['id'], voterAddress: string) { + inBackground(async () => { + const proposal = await ProposalService.getProposal(proposalId) + const votes = await SnapshotService.getVotesByProposal(proposal.snapshot_id) + const addressVote = votes.find((vote) => isSameAddress(vote.voter, voterAddress)) + + if ((addressVote?.vp || 0) >= Number(process.env.WHALE_VOTE_THRESHOLD)) { + this.whaleVote(proposal) + } + + const delegators = await SnapshotSubgraph.get().getDelegates('delegatedFrom', { + address: voterAddress, + space: proposal.snapshot_space, + blockNumber: proposal.snapshot_proposal.snapshot, + }) + if (delegators.length === 0) { + return + } + + const votesAddresses = votes.map((vote) => vote.voter) + const delegatorsWhoVoted = votesAddresses.filter((vote) => + delegators.some((delegator) => delegator.delegator === vote) + ) + if (delegatorsWhoVoted.length > 0) { + this.votedOnBehalf(proposal, delegatorsWhoVoted) + } + }) + } + + private static whaleVote(proposal: ProposalAttributes) { + inBackground(async () => { + try { + const addresses = await this.getAuthorAndCoauthors(proposal) + const title = Notifications.WhaleVote.title(proposal) + const body = Notifications.WhaleVote.body + + DiscordService.sendDirectMessages(addresses, { + title, + action: body, + url: proposalUrl(proposal.id), + fields: [], + }) + + const dclNotifications = addresses.map((address) => ({ + type: 'governance_whale_vote', + address, + eventKey: proposal.id, + metadata: { + proposalId: proposal.id, + proposalTitle: proposal.title, + title, + description: body, + link: proposalUrl(proposal.id), + }, + timestamp: Date.now(), + })) + + await Promise.all([ + this.sendPushNotification({ + title, + body, + recipient: addresses, + url: proposalUrl(proposal.id), + customType: NotificationCustomType.WhaleVote, + }), + this.sendDCLNotifications(dclNotifications), + ]) + } catch (error) { + ErrorService.report('Error sending notifications for whale vote', { + error: `${error}`, + category: ErrorCategory.Notifications, + proposal_id: proposal.id, + }) + } + }) + } + + private static votedOnBehalf(proposal: ProposalAttributes, addresses: string[]) { + inBackground(async () => { + try { + const title = Notifications.VotedOnYourBehalf.title(proposal) + const body = Notifications.VotedOnYourBehalf.body + + DiscordService.sendDirectMessages(addresses, { + title, + action: body, + url: proposalUrl(proposal.id), + fields: [], + }) + + const dclNotifications = addresses.map((address) => ({ + type: 'governance_voted_on_behalf', + address, + eventKey: proposal.id, + metadata: { + proposalId: proposal.id, + proposalTitle: proposal.title, + title, + description: body, + link: proposalUrl(proposal.id), + }, + timestamp: Date.now(), + })) + + await Promise.all([ + this.sendPushNotification({ + title, + body, + recipient: addresses, + url: proposalUrl(proposal.id), + customType: NotificationCustomType.VotedOnBehalf, + }), + this.sendDCLNotifications(dclNotifications), + ]) + } catch (error) { + ErrorService.report('Error sending notifications for delegated vote', { + error: `${error}`, + category: ErrorCategory.Notifications, + proposal_id: proposal.id, + }) + } + }) + } } diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index 044eaf1f0..99b6a3065 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -26,6 +26,8 @@ export enum NotificationCustomType { Grant = 'grant', TenderPassed = 'tender_passed', PitchPassed = 'pitch_passed', + WhaleVote = 'whale_vote', + VotedOnBehalf = 'voted_on_behalf', } export type Notification = { diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts index f74502f29..67bc74317 100644 --- a/src/utils/notifications.ts +++ b/src/utils/notifications.ts @@ -60,4 +60,12 @@ export const Notifications = { title: (proposal: ProposalAttributes) => `The Tender "${proposal.title}" can now receive Bid Projects`, body: 'If think you can tackle this solution, propose a Project and get funding from the DAO', }, + WhaleVote: { + title: (proposal: ProposalAttributes) => `A whale voted on your proposal "${proposal.title}"`, + body: 'A wallet holding over 250k VP has just cast a vote. Stay informed and see how this significant vote impacts the outcome.', + }, + VotedOnYourBehalf: { + 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.', + }, } diff --git a/strategy.json b/strategy.json new file mode 100644 index 000000000..f1ad7d2d3 --- /dev/null +++ b/strategy.json @@ -0,0 +1,104 @@ +{ + "symbol": "VP (delegated)", + "strategies": [ + { + "name": "erc20-balance-of", + "params": { + "symbol": "WMANA", + "address": "0xfd09cf7cfffa9932e33668311c4777cb9db3c9be", + "decimals": 18 + } + }, + { + "name": "erc721-with-multiplier", + "params": { + "symbol": "LAND", + "address": "0xf87e31492faf9a91b02ee0deaad50d51d56d5d4d", + "multiplier": 2000 + } + }, + { + "name": "decentraland-estate-size", + "params": { + "symbol": "ESTATE", + "address": "0x959e104e1a4db6317fa58f8295f586e1a978c297", + "multiplier": 2000 + } + }, + { + "name": "multichain", + "params": { + "name": "multichain", + "graphs": { + "137": "https://subgraph.decentraland.org/blocks-matic-mainnet" + }, + "symbol": "MANA", + "strategies": [ + { + "name": "erc20-balance-of", + "params": { + "address": "0x0f5d2fb29fb7d3cfee444a200298f468908cc942", + "decimals": 18 + }, + "network": "1" + }, + { + "name": "erc20-balance-of", + "params": { + "address": "0xA1c57f48F0Deb89f569dFbE6E2B7f46D33606fD4", + "decimals": 18 + }, + "network": "137" + } + ] + } + }, + { + "name": "erc721-with-multiplier", + "params": { + "symbol": "NAMES", + "address": "0x2a187453064356c898cae034eaed119e1663acb8", + "multiplier": 100 + } + }, + { + "name": "decentraland-wearable-rarity", + "params": { + "symbol": "WEARABLE", + "collections": [ + "0x32b7495895264ac9d0b12d32afd435453458b1c6", + "0xd35147be6401dcb20811f2104c33de8e97ed6818", + "0xc04528c14c8ffd84c7c1fb6719b4a89853035cdd", + "0xc1f4b0eea2bd6690930e6c66efd3e197d620b9c2", + "0xf64dc33a192e056bb5f0e5049356a0498b502d50", + "0xc3af02c0fd486c8e9da5788b915d6fff3f049866" + ], + "multipliers": { + "epic": 10, + "rare": 5, + "mythic": 1000, + "uncommon": 1, + "legendary": 100 + } + } + }, + { + "name": "decentraland-rental-lessors", + "params": { + "symbol": "RENTAL", + "addresses": { + "land": "0xf87e31492faf9a91b02ee0deaad50d51d56d5d4d", + "estate": "0x959e104e1a4db6317fa58f8295f586e1a978c297" + }, + "subgraphs": { + "rentals": "https://subgraph.decentraland.org/rentals-ethereum-mainnet", + "marketplace": "https://subgraph.decentraland.org/marketplace" + }, + "multipliers": { + "land": 2000, + "estateSize": 2000 + } + } + } + ] +}