From d18f7647d5609d1c126305fea67786bfcd943ef2 Mon Sep 17 00:00:00 2001 From: Andy Espagnolo Date: Mon, 28 Aug 2023 15:18:28 -0300 Subject: [PATCH] feat: use snapshot api key in all snapshot requests (#1211) * feat: move scores calculation to backend * fix: scores not working on sepolia * refactor: move getProposalScores to backend --- package-lock.json | 69 +++++++++++++++++++------- package.json | 2 +- src/back/routes/snapshot.ts | 32 +++++++++--- src/back/utils/validations.ts | 6 +++ src/clients/Governance.ts | 25 +++++++++- src/clients/SnapshotApi.ts | 39 +++++++++------ src/clients/SnapshotGraphql.ts | 1 + src/entities/Votes/utils.ts | 46 +---------------- src/hooks/useProposalOutcome.ts | 10 +--- src/hooks/useVotingPowerBalanceList.ts | 4 +- src/services/SnapshotService.ts | 39 ++++++++++++++- 11 files changed, 172 insertions(+), 101 deletions(-) diff --git a/package-lock.json b/package-lock.json index d151edc30..70398997c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@dcl/ui-env": "1.2.1", "@jparnaudo/react-crypto-icons": "^1.0.5", "@otterspace-xyz/contracts": "^2.7.3", - "@snapshot-labs/snapshot.js": "0.4.52", + "@snapshot-labs/snapshot.js": "0.5.5", "@tanstack/react-query": "^4.29.7", "autoprefixer": "^10.4.4", "chart.js": "^3.8.2", @@ -6591,21 +6591,22 @@ } }, "node_modules/@snapshot-labs/snapshot.js": { - "version": "0.4.52", - "resolved": "https://registry.npmjs.org/@snapshot-labs/snapshot.js/-/snapshot.js-0.4.52.tgz", - "integrity": "sha512-gm9tQL1FtpjSXf0jGVQgaf31KBMEjUe0ZQPzttu9GvRKCwBAEQosu7T3lcQvUpgIXz87aRAMzFQHouqoMg9rOw==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@snapshot-labs/snapshot.js/-/snapshot.js-0.5.5.tgz", + "integrity": "sha512-JNBGdqim9+IriPRv0R8vhLe3ojVv7i2R1qrx6PxQCbdHwiBr7d7Npei+h8VpnzOKYcVzPAkttOedxDvxxR2wnA==", "dependencies": { "@ensdomains/eth-ens-namehash": "^2.0.15", "@ethersproject/abi": "^5.6.4", "@ethersproject/address": "^5.6.1", "@ethersproject/bytes": "^5.6.1", "@ethersproject/contracts": "^5.6.2", - "@ethersproject/hash": "^5.6.1", + "@ethersproject/hash": "^5.7.0", "@ethersproject/providers": "^5.6.8", + "@ethersproject/units": "^5.7.0", "@ethersproject/wallet": "^5.6.2", "ajv": "^8.11.0", "ajv-formats": "^2.1.1", - "cross-fetch": "^3.1.5", + "cross-fetch": "^3.1.6", "json-to-graphql-query": "^2.2.4", "lodash.set": "^4.3.2" }, @@ -14160,11 +14161,30 @@ } }, "node_modules/cross-fetch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", - "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, + "node_modules/cross-fetch/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "dependencies": { - "node-fetch": "2.6.7" + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } } }, "node_modules/cross-spawn": { @@ -48428,21 +48448,22 @@ } }, "@snapshot-labs/snapshot.js": { - "version": "0.4.52", - "resolved": "https://registry.npmjs.org/@snapshot-labs/snapshot.js/-/snapshot.js-0.4.52.tgz", - "integrity": "sha512-gm9tQL1FtpjSXf0jGVQgaf31KBMEjUe0ZQPzttu9GvRKCwBAEQosu7T3lcQvUpgIXz87aRAMzFQHouqoMg9rOw==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@snapshot-labs/snapshot.js/-/snapshot.js-0.5.5.tgz", + "integrity": "sha512-JNBGdqim9+IriPRv0R8vhLe3ojVv7i2R1qrx6PxQCbdHwiBr7d7Npei+h8VpnzOKYcVzPAkttOedxDvxxR2wnA==", "requires": { "@ensdomains/eth-ens-namehash": "^2.0.15", "@ethersproject/abi": "^5.6.4", "@ethersproject/address": "^5.6.1", "@ethersproject/bytes": "^5.6.1", "@ethersproject/contracts": "^5.6.2", - "@ethersproject/hash": "^5.6.1", + "@ethersproject/hash": "^5.7.0", "@ethersproject/providers": "^5.6.8", + "@ethersproject/units": "^5.7.0", "@ethersproject/wallet": "^5.6.2", "ajv": "^8.11.0", "ajv-formats": "^2.1.1", - "cross-fetch": "^3.1.5", + "cross-fetch": "^3.1.6", "json-to-graphql-query": "^2.2.4", "lodash.set": "^4.3.2" }, @@ -54499,11 +54520,21 @@ } }, "cross-fetch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", - "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", "requires": { - "node-fetch": "2.6.7" + "node-fetch": "^2.6.12" + }, + "dependencies": { + "node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "requires": { + "whatwg-url": "^5.0.0" + } + } } }, "cross-spawn": { diff --git a/package.json b/package.json index 0ba770d70..967d517ac 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "@dcl/ui-env": "1.2.1", "@jparnaudo/react-crypto-icons": "^1.0.5", "@otterspace-xyz/contracts": "^2.7.3", - "@snapshot-labs/snapshot.js": "0.4.52", + "@snapshot-labs/snapshot.js": "0.5.5", "@tanstack/react-query": "^4.29.7", "autoprefixer": "^10.4.4", "chart.js": "^3.8.2", diff --git a/src/back/routes/snapshot.ts b/src/back/routes/snapshot.ts index 25b2d1297..c0c8410de 100644 --- a/src/back/routes/snapshot.ts +++ b/src/back/routes/snapshot.ts @@ -5,16 +5,18 @@ import { Request } from 'express' import { SnapshotVote } from '../../clients/SnapshotGraphqlTypes' import { SnapshotService } from '../../services/SnapshotService' -import { validateAddress, validateDates, validateFields } from '../utils/validations' +import { validateAddress, validateDates, validateFields, validateProposalSnapshotId } from '../utils/validations' export default routes((router) => { router.get('/snapshot/status-space/:spaceName', handleAPI(getStatusAndSpace)) router.post('/snapshot/votes', handleAPI(getAddressesVotes)) - router.get('/snapshot/votes/:id', handleAPI(getProposalVotes)) + router.get('/snapshot/votes/:proposalSnapshotId', handleAPI(getProposalVotes)) router.post('/snapshot/votes/all', handleAPI(getAllVotesBetweenDates)) router.post('/snapshot/proposals', handleAPI(getProposals)) router.post('/snapshot/proposals/pending', handleAPI(getPendingProposals)) router.get('/snapshot/vp-distribution/:address/:proposalSnapshotId?', handleAPI(getVpDistribution)) + router.post('/snapshot/scores', handleAPI(getScores)) + router.get('/snapshot/proposal-scores/:proposalSnapshotId', handleAPI(getProposalScores)) }) async function getStatusAndSpace(req: Request<{ spaceName?: string }>) { @@ -27,13 +29,11 @@ async function getAddressesVotes(req: Request) { return await SnapshotService.getAddressesVotes(addresses) } -async function getProposalVotes(req: Request<{ id?: string }>) { - const { id } = req.params - if (!id || id.length === 0) { - throw new RequestError('Invalid snapshot id') - } +async function getProposalVotes(req: Request<{ proposalSnapshotId?: string }>) { + const { proposalSnapshotId } = req.params + validateProposalSnapshotId(proposalSnapshotId) - return await SnapshotService.getProposalVotes(id!) + return await SnapshotService.getProposalVotes(proposalSnapshotId!) } async function getAllVotesBetweenDates(req: Request): Promise { @@ -65,3 +65,19 @@ async function getVpDistribution(req: Request<{ address: string; proposalSnapsho return await SnapshotService.getVpDistribution(address, proposalSnapshotId) } + +async function getScores(req: Request) { + const addresses = req.body.addresses + if (!addresses || addresses.length === 0) { + throw new RequestError('Addresses missing', RequestError.BadRequest) + } + + return await SnapshotService.getScores(addresses) +} + +async function getProposalScores(req: Request<{ proposalSnapshotId?: string }>) { + const { proposalSnapshotId } = req.params + validateProposalSnapshotId(proposalSnapshotId) + + return await SnapshotService.getProposalScores(proposalSnapshotId!) +} diff --git a/src/back/utils/validations.ts b/src/back/utils/validations.ts index 18d30a10d..44618c47f 100644 --- a/src/back/utils/validations.ts +++ b/src/back/utils/validations.ts @@ -64,3 +64,9 @@ export function validateUniqueAddresses(addresses: string[]): boolean { return uniqueSet.size === addresses.length } + +export function validateProposalSnapshotId(proposalSnapshotId?: string) { + if (!proposalSnapshotId || proposalSnapshotId.length === 0) { + throw new RequestError('Invalid snapshot id') + } +} diff --git a/src/clients/Governance.ts b/src/clients/Governance.ts index 700775f1f..64651cbdf 100644 --- a/src/clients/Governance.ts +++ b/src/clients/Governance.ts @@ -37,7 +37,14 @@ import { Vote, VotedProposal } from '../entities/Votes/types' import Time from '../utils/date/Time' import { TransparencyBudget } from './DclData' -import { SnapshotProposal, SnapshotSpace, SnapshotStatus, SnapshotVote, VpDistribution } from './SnapshotGraphqlTypes' +import { + DetailedScores, + SnapshotProposal, + SnapshotSpace, + SnapshotStatus, + SnapshotVote, + VpDistribution, +} from './SnapshotGraphqlTypes' import { VestingInfo } from './VestingData' type NewProposalMap = { @@ -551,6 +558,14 @@ export class Governance extends API { return response.data } + async getProposalScores(proposalSnapshotId: string) { + const response = await this.fetch>( + `/snapshot/proposal-scores/${proposalSnapshotId}`, + this.options().method('GET') + ) + return response.data + } + async getVpDistribution(address: string, proposalSnapshotId?: string) { const snapshotId = proposalSnapshotId ? `/${proposalSnapshotId}` : '' const url = `/snapshot/vp-distribution/${address}${snapshotId}` @@ -558,6 +573,14 @@ export class Governance extends API { return response.data } + async getScores(addresses: string[]) { + const response = await this.fetch>( + '/snapshot/scores', + this.options().method('POST').json({ addresses }) + ) + return response.data + } + async getVestingContractData(addresses: string[]) { const response = await this.fetch>( `/vesting`, diff --git a/src/clients/SnapshotApi.ts b/src/clients/SnapshotApi.ts index 49c10cdc5..eb483e0b4 100644 --- a/src/clients/SnapshotApi.ts +++ b/src/clients/SnapshotApi.ts @@ -6,14 +6,18 @@ import { CancelProposal, ProposalType, Vote } from '@snapshot-labs/snapshot.js/d import logger from 'decentraland-gatsby/dist/entities/Development/logger' import env from 'decentraland-gatsby/dist/utils/env' -import { SNAPSHOT_ADDRESS, SNAPSHOT_PRIVATE_KEY, SNAPSHOT_SPACE } from '../entities/Snapshot/constants' +import { + SNAPSHOT_ADDRESS, + SNAPSHOT_API_KEY, + SNAPSHOT_PRIVATE_KEY, + SNAPSHOT_SPACE, +} from '../entities/Snapshot/constants' import { getChecksumAddress } from '../entities/Snapshot/utils' import { ProposalInCreation, ProposalLifespan } from '../services/ProposalService' import Time from '../utils/date/Time' import { getEnvironmentChainId } from '../utils/votes/utils' import { SnapshotGraphql } from './SnapshotGraphql' -import { SnapshotStrategy } from './SnapshotGraphqlTypes' import { trimLastForwardSlash } from './utils' const SNAPSHOT_PROPOSAL_TYPE: ProposalType = 'single-choice' // Each voter may select only one choice @@ -150,29 +154,32 @@ export class SnapshotApi { return (await this.client.vote(account, address, voteMessage)) as SnapshotReceipt } - async getScores( - addresses: string[], - blockNumber?: number | string, - space?: string, - networkId?: string, - proposalStrategies?: SnapshotStrategy[] - ) { + async getScores(addresses: string[]) { const formattedAddresses = addresses.map((address) => getChecksumAddress(address)) - const network = networkId && networkId.length > 0 ? networkId : getEnvironmentChainId().toString() - const spaceName = space && space.length > 0 ? space : SnapshotApi.getSpaceName() - const strategies = proposalStrategies || (await SnapshotGraphql.get().getSpace(spaceName)).strategies + const spaceName = SnapshotApi.getSpaceName() + const network = getEnvironmentChainId().toString() + const strategies = (await SnapshotGraphql.get().getSpace(spaceName)).strategies + const scoreApiUrl = `https://score.snapshot.org/?apiKey=${SNAPSHOT_API_KEY}` try { - const scores = await snapshot.utils.getScores(spaceName, strategies, network, formattedAddresses, blockNumber) + const scores = await snapshot.utils.getScores( + spaceName, + strategies, + network, + formattedAddresses, + undefined, + scoreApiUrl + ) + return { - scores: scores, - strategies: strategies, + scores, + strategies, } } catch (e) { logger.log( `Space: ${spaceName}, Strategies: ${JSON.stringify( strategies - )}, Network: ${network}, Addresses: ${formattedAddresses}, Block: ${blockNumber}` + )}, Network: ${network}, Addresses: ${formattedAddresses}` ) throw new Error('Error fetching proposal scores', e as Error) } diff --git a/src/clients/SnapshotGraphql.ts b/src/clients/SnapshotGraphql.ts index 1871df257..20b3bfebc 100644 --- a/src/clients/SnapshotGraphql.ts +++ b/src/clients/SnapshotGraphql.ts @@ -70,6 +70,7 @@ export class SnapshotGraphql extends API { strategies { name params + network } } } diff --git a/src/entities/Votes/utils.ts b/src/entities/Votes/utils.ts index db03da881..da1c2a8ab 100644 --- a/src/entities/Votes/utils.ts +++ b/src/entities/Votes/utils.ts @@ -1,13 +1,10 @@ import isUUID from 'validator/lib/isUUID' -import { SnapshotApi } from '../../clients/SnapshotApi' -import { DetailedScores, SnapshotStrategy, SnapshotVote } from '../../clients/SnapshotGraphqlTypes' -import { isSameAddress } from '../Snapshot/utils' +import { SnapshotVote } from '../../clients/SnapshotGraphqlTypes' import { ChoiceColor, Vote } from './types' export type Scores = Record -const DELEGATION_STRATEGY_NAME = 'delegation' export function toProposalIds(ids?: undefined | null | string | string[]) { if (!ids) { @@ -166,44 +163,3 @@ export function abbreviateNumber(vp: number) { function getFloorOrZero(number?: number) { return Math.floor(number || 0) } - -export async function getScores( - addresses: string[], - block?: string | number, - space?: string, - networkId?: string, - proposalStrategies?: SnapshotStrategy[] -) { - const formattedAddresses = addresses.map((addr) => addr.toLowerCase()) - const { scores, strategies } = await SnapshotApi.get().getScores( - formattedAddresses, - block, - space, - networkId, - proposalStrategies - ) - - const result: DetailedScores = {} - const delegationScores = scores[strategies.findIndex((s) => s.name === DELEGATION_STRATEGY_NAME)] || {} - for (const addr of formattedAddresses) { - result[addr] = { - ownVp: 0, - delegatedVp: - Math.round(delegationScores[Object.keys(delegationScores).find((key) => isSameAddress(key, addr)) || '']) || 0, - totalVp: 0, - } - } - - for (const score of scores) { - for (const addr of Object.keys(score)) { - const address = addr.toLowerCase() - result[address].totalVp = (result[address].totalVp || 0) + Math.floor(score[addr] || 0) - } - } - - for (const address of Object.keys(result)) { - result[address].ownVp = result[address].totalVp - result[address].delegatedVp - } - - return result -} diff --git a/src/hooks/useProposalOutcome.ts b/src/hooks/useProposalOutcome.ts index b1e7aad36..cd51b8ab2 100644 --- a/src/hooks/useProposalOutcome.ts +++ b/src/hooks/useProposalOutcome.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query' -import { SnapshotGraphql } from '../clients/SnapshotGraphql' +import { Governance } from '../clients/Governance' import { calculateWinnerChoice, getScoresResult } from '../entities/Proposal/outcomeUtils' import { ProposalAttributes } from '../entities/Proposal/types' @@ -9,13 +9,7 @@ import { FIVE_MINUTES_MS } from './constants' const useProposalOutcome = (snapshotId: ProposalAttributes['snapshot_id'], choices: string[]) => { const { data: scores, isLoading } = useQuery({ queryKey: [`proposalScores#${snapshotId}`], - queryFn: async () => { - if (!snapshotId) { - return null - } - - return SnapshotGraphql.get().getProposalScores(snapshotId) - }, + queryFn: async () => Governance.get().getProposalScores(snapshotId), staleTime: FIVE_MINUTES_MS, enabled: !!snapshotId && !!choices, }) diff --git a/src/hooks/useVotingPowerBalanceList.ts b/src/hooks/useVotingPowerBalanceList.ts index 902b3fedc..2ebe9b7b7 100644 --- a/src/hooks/useVotingPowerBalanceList.ts +++ b/src/hooks/useVotingPowerBalanceList.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query' -import { getScores } from '../entities/Votes/utils' +import { Governance } from '../clients/Governance' import { DEFAULT_QUERY_STALE_TIME } from './constants' @@ -9,7 +9,7 @@ export default function useVotingPowerBalanceList(addresses: string[]) { queryKey: [`votingPower#${JSON.stringify(addresses)}`], queryFn: async () => { if (addresses.length < 1) return {} - return await getScores(addresses) + return await Governance.get().getScores(addresses) }, staleTime: DEFAULT_QUERY_STALE_TIME, }) diff --git a/src/services/SnapshotService.ts b/src/services/SnapshotService.ts index b152eec80..2dea6c32d 100644 --- a/src/services/SnapshotService.ts +++ b/src/services/SnapshotService.ts @@ -3,16 +3,19 @@ import isNumber from 'lodash/isNumber' import { SnapshotApi, SnapshotReceipt } from '../clients/SnapshotApi' import { SnapshotGraphql } from '../clients/SnapshotGraphql' -import { SnapshotProposal, SnapshotVote, VpDistribution } from '../clients/SnapshotGraphqlTypes' +import { DetailedScores, SnapshotProposal, SnapshotVote, VpDistribution } from '../clients/SnapshotGraphqlTypes' import * as templates from '../entities/Proposal/templates' import { proposalUrl, snapshotProposalUrl } from '../entities/Proposal/utils' import { SNAPSHOT_SPACE } from '../entities/Snapshot/constants' +import { isSameAddress } from '../entities/Snapshot/utils' import { inBackground } from '../helpers' import { Avatar } from '../utils/Catalyst/types' import { ProposalInCreation, ProposalLifespan } from './ProposalService' import RpcService from './RpcService' +const DELEGATION_STRATEGY_NAME = 'delegation' + export class SnapshotService { static async createProposal( proposalInCreation: ProposalInCreation, @@ -123,4 +126,38 @@ export class SnapshotService { static async getVpDistribution(address: string, proposalSnapshotId?: string): Promise { return await SnapshotGraphql.get().getVpDistribution(address, proposalSnapshotId) } + + static async getScores(addresses: string[]) { + const formattedAddresses = addresses.map((addr) => addr.toLowerCase()) + const { scores, strategies } = await SnapshotApi.get().getScores(formattedAddresses) + + const result: DetailedScores = {} + const delegationScores = scores[strategies.findIndex((s) => s.name === DELEGATION_STRATEGY_NAME)] || {} + for (const addr of formattedAddresses) { + result[addr] = { + ownVp: 0, + delegatedVp: + Math.round(delegationScores[Object.keys(delegationScores).find((key) => isSameAddress(key, addr)) || '']) || + 0, + totalVp: 0, + } + } + + for (const score of scores) { + for (const addr of Object.keys(score)) { + const address = addr.toLowerCase() + result[address].totalVp = (result[address].totalVp || 0) + Math.floor(score[addr] || 0) + } + } + + for (const address of Object.keys(result)) { + result[address].ownVp = result[address].totalVp - result[address].delegatedVp + } + + return result + } + + static async getProposalScores(proposalSnapshotId: string): Promise { + return await SnapshotGraphql.get().getProposalScores(proposalSnapshotId) + } }