From 9a0014632e5b873a3a145a39a3b0d76e388282a7 Mon Sep 17 00:00:00 2001 From: wa0x6e <495709+wa0x6e@users.noreply.github.com> Date: Wed, 1 Oct 2025 21:13:05 +0400 Subject: [PATCH 1/3] feat: add vote vp_value calculation and refresh mechanism --- src/helpers/entityValue.ts | 15 ++---- src/helpers/votesVpValue.ts | 71 +++++++++++++++++++++++++++ src/index.ts | 2 + src/scores.ts | 3 +- test/unit/helpers/entityValue.test.ts | 61 ++++++++++++++--------- 5 files changed, 116 insertions(+), 36 deletions(-) create mode 100644 src/helpers/votesVpValue.ts diff --git a/src/helpers/entityValue.ts b/src/helpers/entityValue.ts index 765ce1eb..0f354afe 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,12 @@ 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); } diff --git a/src/helpers/votesVpValue.ts b/src/helpers/votesVpValue.ts new file mode 100644 index 00000000..c5eef2b8 --- /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_FINAL, 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_FINAL, 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 22347aca..bfbaeeec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { stop as stopStrategies } from './helpers/strategies'; import { trackTurboStatuses } from './helpers/turbo'; +import refreshVotesVpValue from './helpers/votesVpValue'; const app = express(); @@ -24,6 +25,7 @@ async function startServer() { refreshModeration(); refreshProposalsVpValue(); refreshProposalsScoresValue(); + refreshVotesVpValue(); await initializeStrategies(); refreshStrategies(); diff --git a/src/scores.ts b/src/scores.ts index dfa51974..4864c663 100644 --- a/src/scores.ts +++ b/src/scores.ts @@ -62,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); }); From 433aa175c808c62c17cb37d347957cc4d53fb8c3 Mon Sep 17 00:00:00 2001 From: wa0x6e <495709+wa0x6e@users.noreply.github.com> Date: Wed, 1 Oct 2025 22:54:47 +0400 Subject: [PATCH 2/3] refactor: move getVoteValue to votesVpValue.ts --- src/helpers/entityValue.ts | 14 -------------- src/helpers/votesVpValue.ts | 15 ++++++++++++++- src/scores.ts | 2 +- .../{entityValue.test.ts => votesVpValue.test.ts} | 2 +- 4 files changed, 16 insertions(+), 17 deletions(-) rename test/unit/helpers/{entityValue.test.ts => votesVpValue.test.ts} (97%) diff --git a/src/helpers/entityValue.ts b/src/helpers/entityValue.ts index 0f354afe..b7bb22c9 100644 --- a/src/helpers/entityValue.ts +++ b/src/helpers/entityValue.ts @@ -24,17 +24,3 @@ export async function getVpValueByStrategy(proposal: Proposal): Promise parseFloat(value.toFixed(STRATEGIES_VALUE_PRECISION))); } - -/** - * Calculates the total vote value based on the voting power and the proposal's value per strategy. - * @returns The total vote value, in the currency unit specified by the proposal's vp_value_by_strategy values - **/ -export function getVoteValue(vp_value_by_strategy: number[], vp_by_strategy: number[]): number { - if (!vp_value_by_strategy.length) return 0; - - if (vp_value_by_strategy.length !== vp_by_strategy.length) { - throw new Error('invalid data to compute vote value'); - } - - return vp_value_by_strategy.reduce((sum, value, index) => sum + value * vp_by_strategy[index], 0); -} diff --git a/src/helpers/votesVpValue.ts b/src/helpers/votesVpValue.ts index c5eef2b8..e3859c7a 100644 --- a/src/helpers/votesVpValue.ts +++ b/src/helpers/votesVpValue.ts @@ -1,11 +1,24 @@ 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; +/** + * Calculates the total vote value based on the voting power and the proposal's value per strategy. + * @returns The total vote value, in the currency unit specified by the proposal's vp_value_by_strategy values + **/ +export function getVoteValue(vp_value_by_strategy: number[], vp_by_strategy: number[]): number { + if (!vp_value_by_strategy.length) return 0; + + if (vp_value_by_strategy.length !== vp_by_strategy.length) { + throw new Error('invalid data to compute vote value'); + } + + return vp_value_by_strategy.reduce((sum, value, index) => sum + value * vp_by_strategy[index], 0); +} + type Datum = { id: string; vp_state: string; diff --git a/src/scores.ts b/src/scores.ts index 4864c663..8d4cb61e 100644 --- a/src/scores.ts +++ b/src/scores.ts @@ -1,10 +1,10 @@ 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'; import { getDecryptionKey } from './helpers/shutter'; import { hasStrategyOverride, sha256 } from './helpers/utils'; +import { getVoteValue } from './helpers/votesVpValue'; const scoreAPIUrl = process.env.SCORE_API_URL || 'https://score.snapshot.org'; const FINALIZE_SCORE_SECONDS_DELAY = 60; diff --git a/test/unit/helpers/entityValue.test.ts b/test/unit/helpers/votesVpValue.test.ts similarity index 97% rename from test/unit/helpers/entityValue.test.ts rename to test/unit/helpers/votesVpValue.test.ts index fc02cd3c..40187424 100644 --- a/test/unit/helpers/entityValue.test.ts +++ b/test/unit/helpers/votesVpValue.test.ts @@ -1,4 +1,4 @@ -import { getVoteValue } from '../../../src/helpers/entityValue'; +import { getVoteValue } from '../../../src/helpers/votesVpValue'; describe('getVoteValue', () => { it('should calculate correct vote value with single strategy', () => { From 1926816fa2bb53f1f6e1709b924f38ff976dbb22 Mon Sep 17 00:00:00 2001 From: wa0x6e <495709+wa0x6e@users.noreply.github.com> Date: Wed, 1 Oct 2025 23:02:14 +0400 Subject: [PATCH 3/3] fix: leave vp_value computation only to async script --- src/helpers/votesVpValue.ts | 66 +++++++++++--------- src/scores.ts | 2 - test/unit/helpers/votesVpValue.test.ts | 84 -------------------------- 3 files changed, 38 insertions(+), 114 deletions(-) delete mode 100644 test/unit/helpers/votesVpValue.test.ts diff --git a/src/helpers/votesVpValue.ts b/src/helpers/votesVpValue.ts index e3859c7a..e02ea13d 100644 --- a/src/helpers/votesVpValue.ts +++ b/src/helpers/votesVpValue.ts @@ -1,49 +1,51 @@ +import { capture } from '@snapshot-labs/snapshot-sentry'; import snapshot from '@snapshot-labs/snapshot.js'; +import { z } from 'zod'; import db from './mysql'; import { CB } from '../constants'; -const REFRESH_INTERVAL = 10 * 1000; -const BATCH_SIZE = 100; - -/** - * Calculates the total vote value based on the voting power and the proposal's value per strategy. - * @returns The total vote value, in the currency unit specified by the proposal's vp_value_by_strategy values - **/ -export function getVoteValue(vp_value_by_strategy: number[], vp_by_strategy: number[]): number { - if (!vp_value_by_strategy.length) return 0; - - if (vp_value_by_strategy.length !== vp_by_strategy.length) { - throw new Error('invalid data to compute vote value'); - } - - return vp_value_by_strategy.reduce((sum, value, index) => sum + value * vp_by_strategy[index], 0); -} - type Datum = { id: string; - vp_state: string; - vp_by_strategy: number[]; - vp_value_by_strategy: number[]; + vpState: string; + vpByStrategy: number[]; + vpValueByStrategy: number[]; }; +const REFRESH_INTERVAL = 10 * 1000; +const BATCH_SIZE = 100; + +const datumSchema = z + .object({ + id: z.string(), + vpState: z.string(), + vpValueByStrategy: z.array(z.number().finite()), + vpByStrategy: z.array(z.number().finite()) + }) + .refine(data => data.vpValueByStrategy.length === data.vpByStrategy.length, { + message: 'Array length mismatch: vpValueByStrategy and vpByStrategy must have the same length' + }); + 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 (?) + WHERE proposals.cb IN (?) AND votes.cb = ? ORDER BY votes.created ASC LIMIT ?`; const results = await db.queryAsync(query, [ [CB.PENDING_FINAL, CB.PENDING_COMPUTE, CB.FINAL], - [CB.PENDING_SYNC, CB.PENDING_COMPUTE], + 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; + return { + id: r.id, + vpState: r.vp_state, + vpValueByStrategy: JSON.parse(r.vp_value_by_strategy), + vpByStrategy: JSON.parse(r.vp_by_strategy) + }; }); } @@ -55,11 +57,19 @@ async function refreshVotesVpValues(data: Datum[]) { 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); + const validatedDatum = datumSchema.parse(datum); + const value = validatedDatum.vpValueByStrategy.reduce( + (sum, value, index) => sum + value * validatedDatum.vpByStrategy[index], + 0 + ); - params.push(value, datum.vp_state === 'final' ? CB.FINAL : CB.PENDING_FINAL, datum.id); + params.push( + value, + validatedDatum.vpState === 'final' ? CB.FINAL : CB.PENDING_FINAL, + validatedDatum.id + ); } catch (e) { - console.log(e); + capture(e); params.push(0, CB.INELIGIBLE, datum.id); } } diff --git a/src/scores.ts b/src/scores.ts index 8d4cb61e..34f18dfb 100644 --- a/src/scores.ts +++ b/src/scores.ts @@ -4,7 +4,6 @@ import log from './helpers/log'; import db from './helpers/mysql'; import { getDecryptionKey } from './helpers/shutter'; import { hasStrategyOverride, sha256 } from './helpers/utils'; -import { getVoteValue } from './helpers/votesVpValue'; const scoreAPIUrl = process.env.SCORE_API_URL || 'https://score.snapshot.org'; const FINALIZE_SCORE_SECONDS_DELAY = 60; @@ -166,7 +165,6 @@ export async function updateProposalAndVotes(proposalId: string, force = false) votes = votes.map((vote: any) => { vote.scores = proposal.strategies.map((strategy, i) => scores[i][vote.voter] || 0); vote.balance = vote.scores.reduce((a, b: any) => a + b, 0); - vote.vp_value = getVoteValue(proposal, vote); return vote; }); } diff --git a/test/unit/helpers/votesVpValue.test.ts b/test/unit/helpers/votesVpValue.test.ts deleted file mode 100644 index 40187424..00000000 --- a/test/unit/helpers/votesVpValue.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { getVoteValue } from '../../../src/helpers/votesVpValue'; - -describe('getVoteValue', () => { - it('should calculate correct vote value with single strategy', () => { - const vp_value_by_strategy = [2.5]; - const vp_by_strategy = [100]; - - const result = getVoteValue(vp_value_by_strategy, vp_by_strategy); - - expect(result).toBe(250); - }); - - it('should calculate correct vote value with multiple strategies', () => { - const vp_value_by_strategy = [1.5, 3.0, 0.5]; - const vp_by_strategy = [100, 50, 200]; - - 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 vp_value_by_strategy = [2.0, 1.5]; - const vp_by_strategy = [0, 0]; - - 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 vp_value_by_strategy = [0, 0]; - const vp_by_strategy = [100, 50]; - - const result = getVoteValue(vp_value_by_strategy, vp_by_strategy); - - expect(result).toBe(0); - }); - - it('should handle decimal values correctly', () => { - const vp_value_by_strategy = [0.1, 0.25]; - const vp_by_strategy = [10, 20]; - - 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 vp_value_by_strategy = [1.0, 2.0]; - const vp_by_strategy = [100]; - - 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 vp_value_by_strategy = [1.0]; - const vp_by_strategy = [100, 50]; - - expect(() => getVoteValue(vp_value_by_strategy, vp_by_strategy)).toThrow( - 'invalid data to compute vote value' - ); - }); - - it('should handle empty arrays', () => { - const vp_value_by_strategy = []; - const vp_by_strategy = []; - - 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); - }); -});