From df3afda87391ae2f073c5a435c57ed6cc8964bf8 Mon Sep 17 00:00:00 2001 From: Abdulrahman Abdulraheem <127429439+actvabdulrahman@users.noreply.github.com> Date: Sun, 27 Sep 2026 08:34:47 +0100 Subject: [PATCH] fix: correctly update proposal vote tallies on cast (#969) --- .../keys/key-proposal-votes.service.test.ts | 299 ++++++++++++++++++ .../keys/key-proposal-votes.service.ts | 103 +++++- 2 files changed, 394 insertions(+), 8 deletions(-) create mode 100644 src/modules/keys/key-proposal-votes.service.test.ts diff --git a/src/modules/keys/key-proposal-votes.service.test.ts b/src/modules/keys/key-proposal-votes.service.test.ts new file mode 100644 index 00000000..8c8198f3 --- /dev/null +++ b/src/modules/keys/key-proposal-votes.service.test.ts @@ -0,0 +1,299 @@ +// src/modules/keys/key-proposal-votes.service.test.ts +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + governanceProposal: { + findFirst: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + governanceVote: { + findUnique: jest.fn(), + create: jest.fn(), + }, + activity: { create: jest.fn() }, + keyOwnership: { findUnique: jest.fn() }, + $transaction: jest.fn(), + }, +})); + +jest.mock('../../utils/logger.utils', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +import { prisma } from '../../utils/prisma.utils'; +import { + applyVoteWeight, + castKeyProposalVote, + DuplicateVoteError, + HolderNotEligibleError, + OptionIndexOutOfRangeError, + ProposalTotals, +} from './key-proposal-votes.service'; + +interface MockPrismaClient { + governanceProposal: { + findFirst: jest.Mock; + findUnique: jest.Mock; + update: jest.Mock; + }; + governanceVote: { findUnique: jest.Mock; create: jest.Mock }; + activity: { create: jest.Mock }; + keyOwnership: { findUnique: jest.Mock }; + $transaction: jest.Mock; +} + +const mockPrisma = prisma as unknown as MockPrismaClient; + +const KEY_ID = 'key-1'; +const PROPOSAL_ID = 'prop-1'; +const OPTIONS = ['Yes', 'No']; + +interface ProposalStore { + total: string; + results: Record; + status: string; +} + +function makeStore(): ProposalStore { + return { total: '0', results: {}, status: 'active' }; +} + +/** + * Wires the governanceProposal mocks around a mutable store so the + * in-transaction read (findUnique) and the tally update (update) stay + * consistent across multiple votes in the same test. + */ +function wireProposalMocks(store: ProposalStore, options = OPTIONS) { + mockPrisma.governanceProposal.findFirst.mockImplementation(async () => ({ + status: store.status, + options, + })); + + mockPrisma.governanceProposal.findUnique.mockImplementation(async () => ({ + totalVotingWeight: store.total, + results: store.results, + status: store.status, + })); + + mockPrisma.governanceProposal.update.mockImplementation(async (args: any) => { + store.total = args.data.totalVotingWeight; + store.results = args.data.results; + return { totalVotingWeight: store.total, results: store.results }; + }); +} + +function wireSupportingMocks( + opts: { voted?: boolean; balance?: number } = {} +) { + const { voted = false, balance = 5 } = opts; + mockPrisma.governanceVote.findUnique.mockResolvedValue( + voted ? { id: 'existing-vote' } : null + ); + mockPrisma.governanceVote.create.mockResolvedValue({ id: 'vote-1' }); + mockPrisma.keyOwnership.findUnique.mockResolvedValue({ + balance: balance ?? 0, + }); + mockPrisma.activity.create.mockResolvedValue({}); + mockPrisma.$transaction.mockImplementation(async (cb: any) => cb(mockPrisma)); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockPrisma.$transaction.mockImplementation(async (cb: any) => cb(mockPrisma)); +}); + +describe('applyVoteWeight', () => { + it('adds a single vote to an empty proposal', () => { + const out = applyVoteWeight( + { totalVotingWeight: '0', results: {} }, + '5', + 'Yes' + ); + expect(out.totalVotingWeight).toBe('5'); + expect(out.results).toEqual({ Yes: '5' }); + }); + + it('accumulates the same option across repeated calls', () => { + let cur: ProposalTotals = { totalVotingWeight: '0', results: {} }; + cur = applyVoteWeight(cur, '5', 'Yes'); + cur = applyVoteWeight(cur, '3', 'Yes'); + expect(cur.totalVotingWeight).toBe('8'); + expect(cur.results.Yes).toBe('8'); + }); + + it('tracks different options separately while accumulating the total', () => { + let cur: ProposalTotals = { totalVotingWeight: '0', results: {} }; + cur = applyVoteWeight(cur, '5', 'Yes'); + cur = applyVoteWeight(cur, '3', 'No'); + expect(cur.totalVotingWeight).toBe('8'); + expect(cur.results).toEqual({ Yes: '5', No: '3' }); + }); + + it('preserves untouched option buckets', () => { + const out = applyVoteWeight( + { totalVotingWeight: '10', results: { Yes: '6', No: '4' } }, + '2', + 'No' + ); + expect(out.results).toEqual({ Yes: '6', No: '6' }); + expect(out.totalVotingWeight).toBe('12'); + }); + + it('handles very large totals without floating-point drift', () => { + const out = applyVoteWeight( + { + totalVotingWeight: '9999999999999999998', + results: { Yes: '9999999999999999998' }, + }, + '1', + 'Yes' + ); + expect(out.totalVotingWeight).toBe('9999999999999999999'); + expect(out.results.Yes).toBe('9999999999999999999'); + }); + + it('treats empty / undefined tally values as zero', () => { + const empty = applyVoteWeight( + { totalVotingWeight: '', results: {} }, + '7', + 'Yes' + ); + expect(empty.totalVotingWeight).toBe('7'); + expect(empty.results.Yes).toBe('7'); + + const undefinedResults = applyVoteWeight( + { totalVotingWeight: '0', results: undefined as unknown as Record }, + '4', + 'Yes' + ); + expect(undefinedResults.totalVotingWeight).toBe('4'); + expect(undefinedResults.results.Yes).toBe('4'); + }); +}); + +describe('castKeyProposalVote tallying', () => { + it('updates totalVotingWeight and results for a single vote', async () => { + const store = makeStore(); + wireProposalMocks(store); + wireSupportingMocks({ voted: false, balance: 5 }); + + const res = await castKeyProposalVote(KEY_ID, PROPOSAL_ID, 0, 'walletA'); + + expect(res.weight).toBe('5'); + expect(res.option).toBe('Yes'); + + const updateCall = mockPrisma.governanceProposal.update.mock.calls[0]; + expect(updateCall[0].data.totalVotingWeight).toBe('5'); + expect(updateCall[0].data.results).toEqual({ Yes: '5' }); + + expect(mockPrisma.governanceVote.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + voter: 'walletA', + optionIndex: 0, + keyId: KEY_ID, + proposalId: PROPOSAL_ID, + }), + }) + ); + expect(mockPrisma.activity.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + type: 'GOVERNANCE_PROPOSAL_CREATED', + actor: 'walletA', + payload: expect.objectContaining({ action: 'vote_cast' }), + }), + }) + ); + }); + + it('accumulates tallies across multiple votes cast by different wallets on different options', async () => { + const store = makeStore(); + wireProposalMocks(store); + wireSupportingMocks({ voted: false, balance: 5 }); + + // walletA: 5 keys -> "Yes" + await castKeyProposalVote(KEY_ID, PROPOSAL_ID, 0, 'walletA'); + expect(store.total).toBe('5'); + expect(store.results).toEqual({ Yes: '5' }); + + // walletB holds 3 keys -> "No" + mockPrisma.keyOwnership.findUnique.mockResolvedValue({ balance: 3 }); + + await castKeyProposalVote(KEY_ID, PROPOSAL_ID, 1, 'walletB'); + expect(store.total).toBe('8'); + expect(store.results).toEqual({ Yes: '5', No: '3' }); + + // walletC holds 2 keys -> "Yes" again + mockPrisma.keyOwnership.findUnique.mockResolvedValue({ balance: 2 }); + + await castKeyProposalVote(KEY_ID, PROPOSAL_ID, 0, 'walletC'); + expect(store.total).toBe('10'); + expect(store.results).toEqual({ Yes: '7', No: '3' }); + }); + + it('records a single vote in proposal_votes within the same transaction as the tally update', async () => { + const store = makeStore(); + wireProposalMocks(store); + wireSupportingMocks({ voted: false, balance: 5 }); + + await castKeyProposalVote(KEY_ID, PROPOSAL_ID, 0, 'walletA'); + + expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1); + expect(mockPrisma.governanceVote.create).toHaveBeenCalledTimes(1); + expect(mockPrisma.governanceProposal.update).toHaveBeenCalledTimes(1); + }); + + it('throws DuplicateVoteError when the wallet has already voted', async () => { + const store = makeStore(); + wireProposalMocks(store); + wireSupportingMocks({ voted: true, balance: 5 }); + + await expect( + castKeyProposalVote(KEY_ID, PROPOSAL_ID, 0, 'walletA') + ).rejects.toBeInstanceOf(DuplicateVoteError); + + expect(mockPrisma.governanceProposal.update).not.toHaveBeenCalled(); + expect(mockPrisma.governanceVote.create).not.toHaveBeenCalled(); + }); + + it('throws HolderNotEligibleError when the wallet holds no keys', async () => { + const store = makeStore(); + wireProposalMocks(store); + wireSupportingMocks({ voted: false, balance: 0 }); + + await expect( + castKeyProposalVote(KEY_ID, PROPOSAL_ID, 0, 'walletA') + ).rejects.toBeInstanceOf(HolderNotEligibleError); + + expect(mockPrisma.governanceProposal.update).not.toHaveBeenCalled(); + }); + + it('throws OptionIndexOutOfRangeError for an out-of-range option', async () => { + const store = makeStore(); + wireProposalMocks(store); + wireSupportingMocks({ voted: false, balance: 5 }); + + await expect( + castKeyProposalVote(KEY_ID, PROPOSAL_ID, 99, 'walletA') + ).rejects.toBeInstanceOf(OptionIndexOutOfRangeError); + }); + + it('throws ProposalNotFoundOrClosedError when the proposal is closed at commit time', async () => { + const store = makeStore(); + store.status = 'closed'; + wireProposalMocks(store); + wireSupportingMocks({ voted: false, balance: 5 }); + + await expect( + castKeyProposalVote(KEY_ID, PROPOSAL_ID, 0, 'walletA') + ).rejects.toThrow('Proposal not found or closed'); + + expect(mockPrisma.governanceVote.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/keys/key-proposal-votes.service.ts b/src/modules/keys/key-proposal-votes.service.ts index ba42e707..105ddd95 100644 --- a/src/modules/keys/key-proposal-votes.service.ts +++ b/src/modules/keys/key-proposal-votes.service.ts @@ -26,6 +26,54 @@ export class OptionIndexOutOfRangeError extends Error { } } +/** + * Denormalised tally currently stored on a GovernanceProposal row. + */ +export interface ProposalTotals { + totalVotingWeight: string; + results: Record; +} + +/** + * Parses a tally value (stored as a string, or `0` / undefined when unset) + * into a `bigint` for exact arithmetic. Totals and weights are integer key + * counts, so `BigInt` on the raw string avoids the precision loss that + * `Number(...)` would incur for large accumulated totals. + */ +function toBigInt(value: string | number | undefined | null): bigint { + if (value === undefined || value === null || value === '') return 0n; + return typeof value === 'number' + ? BigInt(Math.round(value)) + : BigInt(value); +} + +/** + * Folds one wallet's `weight` into the proposal's current tallies. + * + * Pure: invoked by {@link castKeyProposalVote} inside its transaction so the + * read-modify-write of the denormalised totals is atomic with the vote insert. + * Key counts are integers, so `bigint` avoids floating-point drift as totals + * accumulate and preserves every existing option bucket. + */ +export function applyVoteWeight( + current: ProposalTotals, + weight: string, + option: string +): ProposalTotals { + const w = toBigInt(weight); + const currentResults = current.results ?? {}; + const newTotal = toBigInt(current.totalVotingWeight) + w; + const newOptionWeight = toBigInt(currentResults[option] ?? 0) + w; + + return { + totalVotingWeight: newTotal.toString(), + results: { + ...currentResults, + [option]: newOptionWeight.toString(), + }, + }; +} + export interface CastVoteResult { proposalId: string; optionIndex: number; @@ -119,6 +167,7 @@ export async function castKeyProposalVote( } const weight = String(balance); + const option = options[optionIndex]; // TODO: submit cast_vote contract call via Stellar SDK // On-chain failure should return 502 before reaching this point. @@ -129,14 +178,51 @@ export async function castKeyProposalVote( proposalId, voter: wallet, optionIndex, - option: options[optionIndex], + option, weight, }, 'Submitting cast_vote contract call' ); - await prisma.$transaction([ - prisma.governanceVote.create({ + // Atomically: re-read the proposal (active check + current tallies), fold + // this wallet's weight into totalVotingWeight/results, persist the updated + // tally, then insert the vote and its activity audit row. Using the + // interactive transaction form ensures the tally write and the vote insert + // commit together — they cannot diverge. + await prisma.$transaction(async tx => { + const proposal = await tx.governanceProposal.findUnique({ + where: { keyId_proposalId: { keyId, proposalId } }, + select: { + totalVotingWeight: true, + results: true, + status: true, + }, + }); + + if (!proposal || proposal.status !== 'active') { + const err = new Error('Proposal not found or closed'); + err.name = 'ProposalNotFoundOrClosedError'; + throw err; + } + + const updated = applyVoteWeight( + { + totalVotingWeight: proposal.totalVotingWeight, + results: proposal.results as Record, + }, + weight, + option + ); + + await tx.governanceProposal.update({ + where: { keyId_proposalId: { keyId, proposalId } }, + data: { + totalVotingWeight: updated.totalVotingWeight, + results: updated.results, + }, + }); + + await tx.governanceVote.create({ data: { keyId, proposalId, @@ -144,8 +230,9 @@ export async function castKeyProposalVote( optionIndex, weight: new Decimal(weight), }, - }), - prisma.activity.create({ + }); + + await tx.activity.create({ data: { type: 'GOVERNANCE_PROPOSAL_CREATED', actor: wallet, @@ -155,12 +242,12 @@ export async function castKeyProposalVote( proposalId, action: 'vote_cast', optionIndex, - option: options[optionIndex], + option, weight, }, }, - }), - ]); + }); + }); return { proposalId,