diff --git a/src/helpers/entityValue.ts b/src/helpers/entityValue.ts new file mode 100644 index 00000000..e4e4c56e --- /dev/null +++ b/src/helpers/entityValue.ts @@ -0,0 +1,46 @@ +type Proposal = { + scores_by_strategy: number[][]; + vp_value_by_strategy: number[]; +}; + +/** + * 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(proposal: Proposal): number { + const { scores_by_strategy, vp_value_by_strategy } = proposal; + + if ( + !scores_by_strategy.length || + !scores_by_strategy[0]?.length || + !vp_value_by_strategy.length + ) { + return 0; + } + + let totalValue = 0; + for (let strategyIndex = 0; strategyIndex < vp_value_by_strategy.length; strategyIndex++) { + const strategyTotal = scores_by_strategy.reduce((sum, voteScores) => { + if (voteScores.length !== vp_value_by_strategy.length) { + throw new Error( + 'Array size mismatch: voteScores length does not match vp_value_by_strategy length' + ); + } + 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 vp_value_by_strategy[strategyIndex] !== 'number') { + throw new Error( + `Invalid vp_value: expected number, got ${typeof vp_value_by_strategy[strategyIndex]}` + ); + } + + totalValue += strategyTotal * vp_value_by_strategy[strategyIndex]; + } + + return totalValue; +} diff --git a/src/scores.ts b/src/scores.ts index 671163ff..7e4584f5 100644 --- a/src/scores.ts +++ b/src/scores.ts @@ -1,4 +1,5 @@ import snapshot from '@snapshot-labs/snapshot.js'; +import { getProposalValue } from './helpers/entityValue'; import log from './helpers/log'; import db from './helpers/mysql'; import { getDecryptionKey } from './helpers/shutter'; @@ -16,6 +17,7 @@ async function getProposal(id: string): Promise { proposal.choices = JSON.parse(proposal.choices); proposal.scores = JSON.parse(proposal.scores); proposal.scores_by_strategy = JSON.parse(proposal.scores_by_strategy); + proposal.vp_value_by_strategy = JSON.parse(proposal.vp_value_by_strategy); let proposalState = 'pending'; const ts = parseInt((Date.now() / 1e3).toFixed()); if (ts > proposal.start) proposalState = 'active'; @@ -97,6 +99,12 @@ async function updateProposalScores(proposalId: string, scores: any, votes: numb ]); } +async function updateProposalScoresValue(proposalId: string) { + const proposal = await getProposal(proposalId); + const query = 'UPDATE proposals SET scores_total_value = ? WHERE id = ? LIMIT 1;'; + await db.queryAsync(query, [getProposalValue(proposal), proposalId]); +} + const pendingRequests = {}; export async function updateProposalAndVotes(proposalId: string, force = false) { @@ -186,6 +194,9 @@ export async function updateProposalAndVotes(proposalId: string, force = false) ); delete pendingRequests[proposalId]; + + await updateProposalScoresValue(proposalId); + return true; } catch (e) { delete pendingRequests[proposalId]; diff --git a/test/schema.sql b/test/schema.sql index 59875ac3..05a8b2c1 100644 --- a/test/schema.sql +++ b/test/schema.sql @@ -64,7 +64,7 @@ CREATE TABLE proposals ( scores_state VARCHAR(24) NOT NULL DEFAULT '', scores_total DECIMAL(64,30) NOT NULL, scores_updated INT(11) NOT NULL, - scores_total_value DECIMAL(64,30) NOT NULL DEFAULT '0.000000000000000000000000000000', + scores_total_value DECIMAL(13,3) NOT NULL DEFAULT 0.000, vp_value_by_strategy json NOT NULL, votes INT(12) NOT NULL, flagged INT NOT NULL DEFAULT 0, diff --git a/test/unit/helpers/entityValue.test.ts b/test/unit/helpers/entityValue.test.ts new file mode 100644 index 00000000..533ba1cd --- /dev/null +++ b/test/unit/helpers/entityValue.test.ts @@ -0,0 +1,157 @@ +import { getProposalValue } from '../../../src/helpers/entityValue'; + +describe('getProposalValue', () => { + it('should calculate correct proposal value with single strategy', () => { + const proposal = { + scores_by_strategy: [[100], [200]], + vp_value_by_strategy: [2.5] + }; + + const result = getProposalValue(proposal); + + expect(result).toBe(750); // (100 + 200) * 2.5 = 300 * 2.5 = 750 + }); + + it('should calculate correct proposal value with multiple strategies', () => { + const proposal = { + scores_by_strategy: [ + [100, 50], + [200, 75], + [300, 25] + ], + vp_value_by_strategy: [1.5, 3.0] + }; + + const result = getProposalValue(proposal); + + expect(result).toBe(1350); // (100+200+300)*1.5 + (50+75+25)*3.0 = 600*1.5 + 150*3.0 = 900 + 450 = 1350 + }); + + it('should return 0 when scores_by_strategy is empty', () => { + const proposal = { + scores_by_strategy: [], + vp_value_by_strategy: [2.0] + }; + + const result = getProposalValue(proposal); + + expect(result).toBe(0); + }); + + it('should return 0 when first strategy array is empty', () => { + const proposal = { + scores_by_strategy: [[]], + vp_value_by_strategy: [2.0] + }; + + const result = getProposalValue(proposal); + + expect(result).toBe(0); + }); + + it('should return 0 when vp_value_by_strategy is empty', () => { + const proposal = { + scores_by_strategy: [[100], [200]], + vp_value_by_strategy: [] + }; + + const result = getProposalValue(proposal); + + expect(result).toBe(0); + }); + + it('should handle zero values correctly', () => { + const proposal = { + scores_by_strategy: [ + [0, 0], + [0, 0] + ], + vp_value_by_strategy: [2.0, 1.5] + }; + + const result = getProposalValue(proposal); + + expect(result).toBe(0); + }); + + it('should handle zero vp_value_by_strategy correctly', () => { + const proposal = { + scores_by_strategy: [ + [100, 50], + [200, 75] + ], + vp_value_by_strategy: [0, 0] + }; + + const result = getProposalValue(proposal); + + expect(result).toBe(0); + }); + + it('should handle decimal values correctly', () => { + const proposal = { + scores_by_strategy: [ + [10.5, 20.5], + [15.5, 25.5] + ], + vp_value_by_strategy: [0.1, 0.2] + }; + + const result = getProposalValue(proposal); + + expect(result).toBe(11.8); // (10.5+15.5)*0.1 + (20.5+25.5)*0.2 = 26*0.1 + 46*0.2 = 2.6 + 9.2 = 11.8 + }); + + it('should handle single vote scenario', () => { + const proposal = { + scores_by_strategy: [[100]], + vp_value_by_strategy: [2.0] + }; + + const result = getProposalValue(proposal); + + expect(result).toBe(200); // 100 * 2.0 = 200 + }); + + it('should throw on array size mismatch', () => { + const proposal = { + scores_by_strategy: [ + [100, 50], // 2 strategies + [200, 75] // 2 strategies + ], + vp_value_by_strategy: [1.5] // Only 1 strategy value + }; + + expect(() => getProposalValue(proposal)).toThrow( + 'Array size mismatch: voteScores length does not match vp_value_by_strategy length' + ); + }); + + it('should throw on invalid score value', () => { + const proposal = { + scores_by_strategy: [ + [100, 'invalid'], // Invalid string value + [200, 75] + ], + vp_value_by_strategy: [1.5, 2.0] + } as any; + + expect(() => getProposalValue(proposal)).toThrow( + 'Invalid score value: expected number, got string' + ); + }); + + it('should throw on invalid vp_value', () => { + const proposal = { + scores_by_strategy: [ + [100, 50], + [200, 75] + ], + vp_value_by_strategy: [1.5, 'invalid'] // Invalid string value + } as any; + + expect(() => getProposalValue(proposal)).toThrow( + 'Invalid vp_value: expected number, got string' + ); + }); +});