diff --git a/src/helpers/entityValue.ts b/src/helpers/entityValue.ts index 765ce1eb..a81209e4 100644 --- a/src/helpers/entityValue.ts +++ b/src/helpers/entityValue.ts @@ -1,9 +1,5 @@ import { jsonRpcRequest } from './utils'; -type Vote = { - vp_by_strategy: number[]; -}; - type Proposal = { network: string; strategies: any[]; @@ -33,15 +29,55 @@ export async function getVpValueByStrategy(proposal: Proposal): Promise sum + value * vote.vp_by_strategy[index], - 0 - ); + return vp_value_by_strategy.reduce((sum, value, index) => sum + value * vp_by_strategy[index], 0); +} + +/** + * Calculates the proposal total value based on all votes' total voting power and the proposal's value per strategy. + * @returns The total value of the given proposal's votes, in the currency unit specified by the proposal's vp_value_by_strategy values + */ +export function getProposalValue( + scoresByStrategy: number[][], + vpValueByStrategy: number[] +): number { + if (!scoresByStrategy.length || !scoresByStrategy[0]?.length || !vpValueByStrategy.length) { + return 0; + } + + // Validate that all voteScores arrays have the same length as vp_value_by_strategy + for (const voteScores of scoresByStrategy) { + if (voteScores.length !== vpValueByStrategy.length) { + throw new Error( + 'Array size mismatch: voteScores length does not match vp_value_by_strategy length' + ); + } + } + + let totalValue = 0; + for (let strategyIndex = 0; strategyIndex < vpValueByStrategy.length; strategyIndex++) { + const strategyTotal = scoresByStrategy.reduce((sum, voteScores) => { + const score = voteScores[strategyIndex]; + if (typeof score !== 'number') { + throw new Error(`Invalid score value: expected number, got ${typeof score}`); + } + return sum + score; + }, 0); + + if (typeof vpValueByStrategy[strategyIndex] !== 'number') { + throw new Error( + `Invalid vp_value: expected number, got ${typeof vpValueByStrategy[strategyIndex]}` + ); + } + + totalValue += strategyTotal * vpValueByStrategy[strategyIndex]; + } + + return totalValue; } diff --git a/src/helpers/proposalStrategiesValue.ts b/src/helpers/proposalStrategiesValue.ts index 96bfc219..c66d9666 100644 --- a/src/helpers/proposalStrategiesValue.ts +++ b/src/helpers/proposalStrategiesValue.ts @@ -1,3 +1,4 @@ +// import { capture } from '@snapshot-labs/snapshot-sentry'; import snapshot from '@snapshot-labs/snapshot.js'; import { getVpValueByStrategy } from './entityValue'; import db from './mysql'; @@ -57,14 +58,16 @@ async function refreshVpByStrategy(proposals: Proposal[]) { export default async function run() { while (true) { - const proposals = await getProposals(); + while (true) { + const proposals = await getProposals(); + + if (proposals.length === 0) break; - if (proposals.length) { await refreshVpByStrategy(proposals); - } - if (proposals.length < BATCH_SIZE) { - await snapshot.utils.sleep(REFRESH_INTERVAL); + if (proposals.length < BATCH_SIZE) break; } + + await snapshot.utils.sleep(REFRESH_INTERVAL); } } diff --git a/src/helpers/proposalsScoresValue.ts b/src/helpers/proposalsScoresValue.ts new file mode 100644 index 00000000..c98f3aa3 --- /dev/null +++ b/src/helpers/proposalsScoresValue.ts @@ -0,0 +1,69 @@ +import { capture } from '@snapshot-labs/snapshot-sentry'; +import snapshot from '@snapshot-labs/snapshot.js'; +import { getProposalValue } from './entityValue'; +import db from './mysql'; +import { CB } from '../constants'; + +type Proposal = { + id: string; + vpValueByStrategy: number[]; + scoresByStrategy: number[][]; +}; + +const REFRESH_INTERVAL = 10 * 1000; +const BATCH_SIZE = 25; + +async function getProposals(): Promise { + const query = ` + SELECT id, vp_value_by_strategy, scores_by_strategy + FROM proposals + WHERE cb = ? AND end < UNIX_TIMESTAMP() AND scores_state = ? + ORDER BY created ASC + LIMIT ? + `; + const proposals = await db.queryAsync(query, [CB.PENDING_CLOSE, 'final', BATCH_SIZE]); + + return proposals.map((p: any) => { + p.scoresByStrategy = JSON.parse(p.vp_value_by_strategy); + p.vpValueByStrategy = JSON.parse(p.scores_by_strategy); + return p; + }); +} + +async function refreshScoresTotal(proposals: Proposal[]) { + const query: string[] = []; + const params: any[] = []; + + proposals.map(proposal => { + query.push('UPDATE proposals SET scores_total_value = ?, cb = ? WHERE id = ? LIMIT 1'); + + try { + const scoresTotalValue = getProposalValue( + proposal.scoresByStrategy, + proposal.vpValueByStrategy + ); + params.push(scoresTotalValue, CB.FINAL, proposal.id); + } catch (e) { + capture(e); + params.push(0, CB.INELIGIBLE, proposal.id); + } + }); + + if (query.length) { + await db.queryAsync(query.join(';'), params); + } +} + +export default async function run() { + while (true) { + const proposals = await getProposals(); + + if (proposals.length) { + await refreshScoresTotal(proposals); + } + + if (proposals.length < BATCH_SIZE) { + await snapshot.utils.sleep(REFRESH_INTERVAL); + } + } +} diff --git a/src/helpers/votesVpValue.ts b/src/helpers/votesVpValue.ts new file mode 100644 index 00000000..2f42a68b --- /dev/null +++ b/src/helpers/votesVpValue.ts @@ -0,0 +1,71 @@ +import snapshot from '@snapshot-labs/snapshot.js'; +import { getVoteValue } from './entityValue'; +import db from './mysql'; +import { CB } from '../constants'; + +const REFRESH_INTERVAL = 10 * 1000; +const BATCH_SIZE = 100; + +type Datum = { + id: string; + vp_state: string; + vp_by_strategy: number[]; + vp_value_by_strategy: number[]; +}; + +async function getVotes(): Promise { + const query = ` + SELECT votes.id, votes.vp_state, votes.vp_by_strategy, proposals.vp_value_by_strategy + FROM votes + JOIN proposals ON votes.proposal = proposals.id + WHERE proposals.cb IN (?) AND votes.cb IN (?) + ORDER BY votes.created ASC + LIMIT ?`; + const results = await db.queryAsync(query, [ + [CB.PENDING_CLOSE, CB.PENDING_COMPUTE, CB.FINAL], + [CB.PENDING_SYNC, CB.PENDING_COMPUTE], + BATCH_SIZE + ]); + + return results.map((r: any) => { + r.vp_value_by_strategy = JSON.parse(r.vp_value_by_strategy); + r.vp_by_strategy = JSON.parse(r.vp_by_strategy); + return r; + }); +} + +async function refreshVotesVpValues(data: Datum[]) { + const query: string[] = []; + const params: any[] = []; + + for (const datum of data) { + query.push('UPDATE votes SET vp_value = ?, cb = ? WHERE id = ? LIMIT 1'); + + try { + const value = getVoteValue(datum.vp_value_by_strategy, datum.vp_by_strategy); + + params.push(value, datum.vp_state === 'final' ? CB.FINAL : CB.PENDING_CLOSE, datum.id); + } catch (e) { + console.log(e); + params.push(0, CB.INELIGIBLE, datum.id); + } + } + + if (query.length) { + await db.queryAsync(query.join(';'), params); + } +} + +export default async function run() { + while (true) { + const votes = await getVotes(); + + if (votes.length) { + await refreshVotesVpValues(votes); + } + + if (votes.length < BATCH_SIZE) { + await snapshot.utils.sleep(REFRESH_INTERVAL); + } + } +} diff --git a/src/index.ts b/src/index.ts index 3cf9d8f2..8946c89b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import api from './api'; import log from './helpers/log'; import initMetrics from './helpers/metrics'; import refreshModeration from './helpers/moderation'; +import refreshProposalsScoresValue from './helpers/proposalsScoresValue'; import refreshProposalsVpValue from './helpers/proposalStrategiesValue'; import rateLimit from './helpers/rateLimit'; import shutter from './helpers/shutter'; @@ -15,6 +16,7 @@ import { stop as stopStrategies } from './helpers/strategies'; import { trackTurboStatuses } from './helpers/turbo'; +import refreshVotesVpValue from './helpers/votesVpValue'; const app = express(); @@ -22,6 +24,8 @@ async function startServer() { initLogger(app); refreshModeration(); refreshProposalsVpValue(); + refreshVotesVpValue(); + refreshProposalsScoresValue(); await initializeStrategies(); refreshStrategies(); diff --git a/src/scores.ts b/src/scores.ts index ec8c2d59..3b23d889 100644 --- a/src/scores.ts +++ b/src/scores.ts @@ -1,4 +1,5 @@ import snapshot from '@snapshot-labs/snapshot.js'; +import { CB } from './constants'; import { getVoteValue } from './helpers/entityValue'; import log from './helpers/log'; import db from './helpers/mysql'; @@ -61,12 +62,13 @@ async function updateVotesVp(votes: any[], vpState: string, proposalId: string) let query = ''; votesInPage.forEach((vote: any) => { query += `UPDATE votes - SET vp = ?, vp_by_strategy = ?, vp_state = ?, vp_value = ? + SET vp = ?, vp_by_strategy = ?, vp_state = ?, vp_value = ?, cb = ? WHERE id = ? AND proposal = ? LIMIT 1; `; params.push(vote.balance); params.push(JSON.stringify(vote.scores)); params.push(vpState); params.push(vote.vp_value); + params.push(CB.PENDING_COMPUTE); params.push(vote.id); params.push(proposalId); }); diff --git a/test/unit/helpers/entityValue.test.ts b/test/unit/helpers/entityValue.test.ts index 7dba57b1..fc02cd3c 100644 --- a/test/unit/helpers/entityValue.test.ts +++ b/test/unit/helpers/entityValue.test.ts @@ -2,69 +2,82 @@ import { getVoteValue } from '../../../src/helpers/entityValue'; describe('getVoteValue', () => { it('should calculate correct vote value with single strategy', () => { - const proposal = { vp_value_by_strategy: [2.5] }; - const vote = { vp_by_strategy: [100] }; + const vp_value_by_strategy = [2.5]; + const vp_by_strategy = [100]; - const result = getVoteValue(proposal, vote); + const result = getVoteValue(vp_value_by_strategy, vp_by_strategy); expect(result).toBe(250); }); it('should calculate correct vote value with multiple strategies', () => { - const proposal = { vp_value_by_strategy: [1.5, 3.0, 0.5] }; - const vote = { vp_by_strategy: [100, 50, 200] }; + const vp_value_by_strategy = [1.5, 3.0, 0.5]; + const vp_by_strategy = [100, 50, 200]; - const result = getVoteValue(proposal, vote); + const result = getVoteValue(vp_value_by_strategy, vp_by_strategy); expect(result).toBe(400); // (1.5 * 100) + (3.0 * 50) + (0.5 * 200) = 150 + 150 + 100 = 400 }); it('should return 0 when vote has no voting power', () => { - const proposal = { vp_value_by_strategy: [2.0, 1.5] }; - const vote = { vp_by_strategy: [0, 0] }; + const vp_value_by_strategy = [2.0, 1.5]; + const vp_by_strategy = [0, 0]; - const result = getVoteValue(proposal, vote); + const result = getVoteValue(vp_value_by_strategy, vp_by_strategy); expect(result).toBe(0); }); it('should return 0 when proposal has no value per strategy', () => { - const proposal = { vp_value_by_strategy: [0, 0] }; - const vote = { vp_by_strategy: [100, 50] }; + const vp_value_by_strategy = [0, 0]; + const vp_by_strategy = [100, 50]; - const result = getVoteValue(proposal, vote); + const result = getVoteValue(vp_value_by_strategy, vp_by_strategy); expect(result).toBe(0); }); it('should handle decimal values correctly', () => { - const proposal = { vp_value_by_strategy: [0.1, 0.25] }; - const vote = { vp_by_strategy: [10, 20] }; + const vp_value_by_strategy = [0.1, 0.25]; + const vp_by_strategy = [10, 20]; - const result = getVoteValue(proposal, vote); + const result = getVoteValue(vp_value_by_strategy, vp_by_strategy); expect(result).toBe(6); // (0.1 * 10) + (0.25 * 20) = 1 + 5 = 6 }); it('should throw error when strategy arrays have different lengths', () => { - const proposal = { vp_value_by_strategy: [1.0, 2.0] }; - const vote = { vp_by_strategy: [100] }; + const vp_value_by_strategy = [1.0, 2.0]; + const vp_by_strategy = [100]; - expect(() => getVoteValue(proposal, vote)).toThrow('invalid data to compute vote value'); + expect(() => getVoteValue(vp_value_by_strategy, vp_by_strategy)).toThrow( + 'invalid data to compute vote value' + ); }); it('should throw error when vote has more strategies than proposal', () => { - const proposal = { vp_value_by_strategy: [1.0] }; - const vote = { vp_by_strategy: [100, 50] }; + const vp_value_by_strategy = [1.0]; + const vp_by_strategy = [100, 50]; - expect(() => getVoteValue(proposal, vote)).toThrow('invalid data to compute vote value'); + expect(() => getVoteValue(vp_value_by_strategy, vp_by_strategy)).toThrow( + 'invalid data to compute vote value' + ); }); it('should handle empty arrays', () => { - const proposal = { vp_value_by_strategy: [] }; - const vote = { vp_by_strategy: [] }; + const vp_value_by_strategy = []; + const vp_by_strategy = []; - const result = getVoteValue(proposal, vote); + const result = getVoteValue(vp_value_by_strategy, vp_by_strategy); + + expect(result).toBe(0); + }); + + it('should return 0 when vp_value_by_strategy is empty', () => { + const vp_value_by_strategy = []; + const vp_by_strategy = [100, 50]; + + const result = getVoteValue(vp_value_by_strategy, vp_by_strategy); expect(result).toBe(0); }); diff --git a/test/unit/writer/proposal.test.ts b/test/unit/writer/proposal.test.ts index 9c98d93e..40acbd09 100644 --- a/test/unit/writer/proposal.test.ts +++ b/test/unit/writer/proposal.test.ts @@ -175,6 +175,45 @@ describe('writer/proposal', () => { expect(mockGetProposalsCount).toHaveBeenCalledTimes(1); }); + describe('when the space has enabled the ticket validation strategy', () => { + it('rejects if the space does not have voting validation', async () => { + expect.assertions(2); + mockGetSpace.mockResolvedValueOnce({ + ...spacesGetSpaceFixtures, + strategies: [{ name: 'ticket' }], + voteValidation: undefined + }); + + await expect(writer.verify(input)).rejects.toMatch('ticket'); + expect(mockGetSpace).toHaveBeenCalledTimes(1); + }); + + it('rejects if the space voting validation is ', async () => { + expect.assertions(2); + mockGetSpace.mockResolvedValueOnce({ + ...spacesGetSpaceFixtures, + strategies: [{ name: 'ticket' }], + voteValidation: { name: 'any' } + }); + + await expect(writer.verify(input)).rejects.toMatch('ticket'); + expect(mockGetSpace).toHaveBeenCalledTimes(1); + }); + + it('does not reject if the space voting validation is anything else valid than ', async () => { + expect.assertions(3); + mockGetSpace.mockResolvedValueOnce({ + ...spacesGetSpaceFixtures, + strategies: [{ name: 'ticket' }], + voteValidation: { name: 'gitcoin' } + }); + + await expect(writer.verify(input)).resolves.toBeDefined(); + expect(mockGetSpace).toHaveBeenCalledTimes(1); + expect(mockGetProposalsCount).toHaveBeenCalledTimes(1); + }); + }); + describe('when the space has set a voting period', () => { const VOTING_PERIOD = 120; const msg = JSON.parse(input.msg); @@ -542,10 +581,94 @@ describe('writer/proposal', () => { ...spacesGetSpaceFixtures }); - await expect(writer.verify(input)).rejects.toMatch( - 'invalid space settings: space validation failed' - ); - expect(mockGetSpace).toHaveBeenCalledTimes(1); + it('rejects if the network does not exist', async () => { + expect.assertions(2); + mockGetSpace.mockResolvedValueOnce({ + ...spacesGetSpaceFixtures, + network: '123abc' + }); + + await expect(writer.verify(input)).rejects.toMatch( + 'invalid space settings: network not allowed' + ); + expect(mockGetSpace).toHaveBeenCalledTimes(1); + }); + + it('rejects if using a non-premium network', async () => { + expect.assertions(2); + mockGetSpace.mockResolvedValueOnce({ + ...spacesGetSpaceFixtures, + network: '56' // Using BSC network, which is not in the premium list + }); + + await expect(writer.verify(input)).rejects.toMatch('space is using a non-premium network'); + expect(mockGetSpace).toHaveBeenCalledTimes(1); + }); + + it('should not reject if using a premium network for turbo space', async () => { + expect.assertions(3); + mockGetSpace.mockResolvedValueOnce({ + ...spacesGetSpaceFixtures, + network: '56', // Using Ethereum mainnet, which is in the premium list + turbo: '1' + }); + + await expect(writer.verify(input)).resolves.toBeDefined(); + expect(mockGetSpace).toHaveBeenCalledTimes(1); + expect(mockGetProposalsCount).toHaveBeenCalledTimes(1); + }); + + it('rejects if strategies use a non-premium network', async () => { + expect.assertions(2); + mockGetSpace.mockResolvedValueOnce({ + ...spacesGetSpaceFixtures, + strategies: [{ name: 'erc20-balance-of', network: '56' }] // Non-premium network + }); + + await expect(writer.verify(input)).rejects.toMatch('space is using a non-premium network'); + expect(mockGetSpace).toHaveBeenCalledTimes(1); + }); + + it('rejects if missing proposal validation', async () => { + expect.assertions(2); + mockGetSpace.mockResolvedValueOnce({ + ...spacesGetSpaceFixtures, + validation: { name: 'any' } + }); + + await expect(writer.verify(input)).rejects.toMatch( + 'invalid space settings: space missing proposal validation' + ); + expect(mockGetSpace).toHaveBeenCalledTimes(1); + }); + + it('rejects if missing vote validation with ticket strategy', async () => { + expect.assertions(2); + mockGetSpace.mockResolvedValueOnce({ + ...spacesGetSpaceFixtures, + validation: { name: 'any' }, + strategies: [{ name: 'ticket' }] + }); + + await expect(writer.verify(input)).rejects.toMatch( + 'invalid space settings: space with ticket requires voting validation' + ); + expect(mockGetSpace).toHaveBeenCalledTimes(1); + }); + + it('rejects if the space was deleted', async () => { + expect.assertions(2); + mockGetSpace.mockResolvedValueOnce({ + ...spacesGetSpaceFixtures, + filters: { onlyMembers: true }, + deleted: true + }); + + await expect(writer.verify(input)).rejects.toMatch( + 'invalid space settings: space deleted, contact admin' + ); + expect(mockGetSpace).toHaveBeenCalledTimes(1); + }); }); describe('when only members can propose', () => {