@snapshot-labs/snapshot.js 0.14.8 → 0.14.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,2 @@
1
1
  import { RpcProvider } from 'starknet';
2
- export default function multicall(address: string, provider: RpcProvider, abi: any[], calls: any[], limit: number, options?: Record<string, any>): Promise<any[][]>;
2
+ export default function multicall(address: string, provider: RpcProvider, abi: any[], calls: any[], limit: number, options?: Record<string, any>): Promise<any[]>;
@@ -55,7 +55,8 @@ export declare function getJSON(uri: any, options?: any): Promise<any>;
55
55
  export declare function ipfsGet(gateway: string, ipfsHash: string, protocolType?: string): Promise<any>;
56
56
  export declare function sendTransaction(web3: any, contractAddress: string, abi: any[], action: string, params: any[], overrides?: {}): Promise<any>;
57
57
  export declare function getScores(space: string, strategies: Strategy[], network: string, addresses: string[], snapshot?: number | string, scoreApiUrl?: string, options?: any): Promise<any>;
58
- export declare function getVp(address: string, network: string, strategies: Strategy[], snapshot: number | 'latest', space: string, delegation: boolean, options?: Options): Promise<any>;
58
+ export declare function getVp(address: string, network: string, strategies: Strategy[], snapshot: number | 'latest', space: string, delegation: false, // @deprecated - kept for backward compatibility for integrators using this function, no longer sent to API
59
+ options?: Options): Promise<any>;
59
60
  export declare function validate(validation: string, author: string, space: string, network: string, snapshot: number | 'latest', params: any, options?: Options): Promise<any>;
60
61
  interface validateSchemaOptions {
61
62
  snapshotEnv?: string;
@@ -1,4 +1,25 @@
1
1
  import { RankedChoiceVote, Strategy } from './types';
2
+ /**
3
+ * Runs the complete Instant Runoff Voting (IRV) algorithm and returns the final results.
4
+ *
5
+ * Executes all elimination rounds until a winner is determined or fewer than 3 candidates remain.
6
+ * Each round eliminates the candidate with the fewest votes and redistributes their votes
7
+ * to voters' next preferences.
8
+ *
9
+ * @param votes - Array of valid ranked choice votes to process
10
+ * @returns Array of tuples representing the final candidate rankings, sorted by vote count (highest first).
11
+ * Each tuple contains [candidateIndex, [totalBalance, scoresArray]] where:
12
+ * - totalBalance: Sum of voting power from all voters who support this candidate
13
+ * - scoresArray: Breakdown of that voting power by voting strategy
14
+ * The relationship: totalBalance === scoresArray.reduce((a,b) => a + b, 0)
15
+ *
16
+ * @example
17
+ * // Returns final results after IRV elimination rounds
18
+ * // [["2", [150, [60,50,40]]], ["1", [120, [70,30,20]]], ...]
19
+ * // Candidate 2 wins with 150 total voting power (60+50+40 from 3 strategies)
20
+ * // Candidate 1 has 120 total voting power (70+30+20 from 3 strategies)
21
+ */
22
+ export declare function getFinalRound(votes: RankedChoiceVote[]): [string, [number, number[]]][];
2
23
  export default class RankedChoiceVoting {
3
24
  proposal: {
4
25
  choices: string[];
@@ -13,6 +34,49 @@ export default class RankedChoiceVoting {
13
34
  getValidVotes(): RankedChoiceVote[];
14
35
  getScores(): number[];
15
36
  getScoresByStrategy(): number[][];
37
+ /**
38
+ * Returns the total voting power from all submitted votes, including invalid ones.
39
+ *
40
+ * This method sums the balance (voting power) from ALL votes submitted to the proposal,
41
+ * regardless of whether they have valid choice arrays. This is useful for calculating
42
+ * total participation, quorum requirements, and percentage of total voting power.
43
+ *
44
+ * Note: This differs from IRV final results which only include valid votes. Invalid votes
45
+ * are excluded from IRV calculations but their voting power is still counted here for
46
+ * participation metrics.
47
+ *
48
+ * @returns Total voting power from all votes (valid + invalid)
49
+ *
50
+ * @example
51
+ * // votes = [
52
+ * // { choice: [1,2,3,4], balance: 1000 }, // Valid
53
+ * // { choice: [1,5,2], balance: 500 }, // Invalid (index 5)
54
+ * // { choice: [2,1,4,3], balance: 750 } // Valid
55
+ * // ]
56
+ * // Returns: 2250 (includes invalid vote's 500 balance)
57
+ */
16
58
  getScoresTotal(): number;
59
+ /**
60
+ * Converts the selected choice indices into a human-readable string representation.
61
+ *
62
+ * Note: This method supports partial ranking where not all available choices
63
+ * need to be selected. The ordinal positions (1st, 2nd, etc.) reflect the
64
+ * order of valid selections only. Invalid choice indices are filtered out.
65
+ *
66
+ * @returns A formatted string showing the ranked choices with ordinal positions.
67
+ * Only valid choices are included, invalid indices are silently ignored.
68
+ *
69
+ * @example
70
+ * // With choices ['Alice', 'Bob', 'Carol', 'David'] and selected [1, 3, 2]
71
+ * // Returns: "(1st) Alice, (2nd) Carol, (3rd) Bob"
72
+ *
73
+ * @example
74
+ * // Partial ranking with choices ['Alice', 'Bob', 'Carol', 'David'] and selected [4, 1]
75
+ * // Returns: "(1st) David, (2nd) Alice"
76
+ *
77
+ * @example
78
+ * // With invalid choice index 5 in selected [1, 5]
79
+ * // Returns: "(1st) Alice" - invalid choice 5 is filtered out
80
+ */
17
81
  getChoiceString(): string;
18
82
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snapshot-labs/snapshot.js",
3
- "version": "0.14.8",
3
+ "version": "0.14.10",
4
4
  "repository": "snapshot-labs/snapshot.js",
5
5
  "license": "MIT",
6
6
  "main": "dist/snapshot.cjs.js",
@@ -17,67 +17,78 @@ function parseStarknetResult(rawResult: string[], functionAbi: any): any {
17
17
  return rawResult;
18
18
  }
19
19
 
20
- const output = functionAbi.outputs[0];
21
- const rawValue = rawResult[0];
20
+ // Parse each output according to its type
21
+ const outputs = functionAbi.outputs;
22
+ const results: any[] = [];
23
+ let rawIndex = 0; // Track position in rawResult array
22
24
 
23
25
  try {
24
- switch (output.type) {
25
- case 'core::felt252':
26
- // Try to decode as shortString (for name, symbol)
27
- try {
28
- return shortString.decodeShortString(rawValue);
29
- } catch {
30
- // If shortString decode fails, return as hex
31
- return rawValue;
32
- }
33
-
34
- // Unsigned integers
35
- case 'core::integer::u8':
36
- case 'core::integer::u16':
37
- case 'core::integer::u32':
38
- case 'core::integer::u64':
39
- return parseInt(rawValue, 16);
40
-
41
- case 'core::integer::u128':
42
- case 'core::integer::usize':
43
- return BigInt(rawValue).toString();
44
-
45
- case 'core::integer::u256':
46
- return uint256.uint256ToBN({
47
- low: rawValue,
48
- high: rawResult[1] || '0x0'
49
- });
50
-
51
- // Signed integers
52
- case 'core::integer::i8':
53
- case 'core::integer::i16':
54
- case 'core::integer::i32':
55
- case 'core::integer::i64':
56
- return parseInt(rawValue, 16);
57
-
58
- case 'core::integer::i128':
59
- return BigInt(rawValue).toString();
60
-
61
- // Boolean type
62
- case 'core::bool':
63
- return rawValue === '0x1' || rawValue === '0x01';
64
-
65
- // Address types
66
- case 'core::starknet::contract_address::ContractAddress':
67
- case 'core::starknet::class_hash::ClassHash':
68
- case 'core::starknet::storage_access::StorageAddress':
69
- return rawValue;
70
-
71
- // Byte array
72
- case 'core::bytes_31::bytes31':
73
- return rawValue;
74
-
75
- default:
76
- // Return raw value for unknown types
77
- return rawValue;
26
+ for (let outputIndex = 0; outputIndex < outputs.length; outputIndex++) {
27
+ const output = outputs[outputIndex];
28
+ const rawValue = rawResult[rawIndex];
29
+
30
+ switch (output.type) {
31
+ case 'core::felt252':
32
+ try {
33
+ results.push(shortString.decodeShortString(rawValue));
34
+ } catch {
35
+ results.push(rawValue);
36
+ }
37
+ rawIndex++;
38
+ break;
39
+ case 'core::integer::u8':
40
+ case 'core::integer::u16':
41
+ case 'core::integer::u32':
42
+ case 'core::integer::u64':
43
+ results.push(parseInt(rawValue, 16));
44
+ rawIndex++;
45
+ break;
46
+ case 'core::integer::u128':
47
+ case 'core::integer::usize':
48
+ results.push(BigInt(rawValue).toString());
49
+ rawIndex++;
50
+ break;
51
+ case 'core::integer::u256':
52
+ results.push(
53
+ uint256.uint256ToBN({
54
+ low: rawValue,
55
+ high: rawResult[rawIndex + 1] || '0x0'
56
+ })
57
+ );
58
+ rawIndex += 2; // u256 uses two slots
59
+ break;
60
+ case 'core::integer::i8':
61
+ case 'core::integer::i16':
62
+ case 'core::integer::i32':
63
+ case 'core::integer::i64':
64
+ results.push(parseInt(rawValue, 16));
65
+ rawIndex++;
66
+ break;
67
+ case 'core::integer::i128':
68
+ results.push(BigInt(rawValue).toString());
69
+ rawIndex++;
70
+ break;
71
+ case 'core::bool':
72
+ results.push(rawValue === '0x1' || rawValue === '0x01');
73
+ rawIndex++;
74
+ break;
75
+ case 'core::starknet::contract_address::ContractAddress':
76
+ case 'core::starknet::class_hash::ClassHash':
77
+ case 'core::starknet::storage_access::StorageAddress':
78
+ results.push(rawValue);
79
+ rawIndex++;
80
+ break;
81
+ case 'core::bytes_31::bytes31':
82
+ results.push(rawValue);
83
+ rawIndex++;
84
+ break;
85
+ default:
86
+ results.push(rawValue);
87
+ rawIndex++;
88
+ }
78
89
  }
90
+ return results;
79
91
  } catch {
80
- // Fallback to raw result if parsing fails
81
92
  return rawResult;
82
93
  }
83
94
  }
@@ -149,6 +160,7 @@ export default async function multicall(
149
160
  const [, functionName] = calls[index];
150
161
  const functionAbi = abi.find((item) => item.name === functionName);
151
162
 
152
- return [parseStarknetResult(result, functionAbi)];
163
+ const parsedResult = parseStarknetResult(result, functionAbi);
164
+ return parsedResult;
153
165
  });
154
166
  }
package/src/networks.json CHANGED
@@ -1570,7 +1570,7 @@
1570
1570
  "url": "https://explorer.celo.org"
1571
1571
  },
1572
1572
  "start": 6599803,
1573
- "logo": "ipfs://QmS2tVJ7rdJRe1NHXAi2L86yCbUwVVrmB2mHQeNdJxvQti"
1573
+ "logo": "ipfs://bafkreidvcofeczigbjr7ddapgdugwso6v2l4iolfxys7qg6kfvu2uduyva"
1574
1574
  },
1575
1575
  "43113": {
1576
1576
  "key": "43113",
@@ -2036,4 +2036,4 @@
2036
2036
  "start": 7,
2037
2037
  "logo": "ipfs://bafkreihbjafyh7eud7r6e5743esaamifcttsvbspfwcrfoc5ykodjdi67m"
2038
2038
  }
2039
- }
2039
+ }
package/src/utils.spec.js CHANGED
@@ -372,7 +372,6 @@ describe('utils', () => {
372
372
  ],
373
373
  snapshot: 7929876,
374
374
  space: 'test.eth',
375
- delegation: false,
376
375
  options: undefined
377
376
  };
378
377
 
@@ -383,7 +382,7 @@ describe('utils', () => {
383
382
  strategies ?? payload.strategies,
384
383
  snapshot ?? payload.snapshot,
385
384
  'test.eth' ?? payload.space,
386
- false ?? payload.delegation,
385
+ false,
387
386
  options ?? payload.options
388
387
  );
389
388
  }
package/src/utils.ts CHANGED
@@ -484,7 +484,7 @@ export async function getVp(
484
484
  strategies: Strategy[],
485
485
  snapshot: number | 'latest',
486
486
  space: string,
487
- delegation: boolean,
487
+ delegation: false, // @deprecated - kept for backward compatibility for integrators using this function, no longer sent to API
488
488
  options?: Options
489
489
  ) {
490
490
  const { url, headers } = formatScoreAPIUrl(options?.url);
@@ -520,8 +520,7 @@ export async function getVp(
520
520
  network,
521
521
  strategies,
522
522
  snapshot,
523
- space,
524
- delegation
523
+ space
525
524
  }
526
525
  })
527
526
  };
@@ -1,11 +1,13 @@
1
- import { test, expect } from 'vitest';
2
- import RankedChoiceVoting from './rankedChoice';
1
+ import { test, expect, describe } from 'vitest';
2
+ import RankedChoiceVoting, { getFinalRound } from './rankedChoice';
3
3
  import example from './examples/rankedChoice.json';
4
4
 
5
+ const TEST_CHOICES = ['Alice', 'Bob', 'Carol', 'David'];
6
+
5
7
  const example2 = () => {
6
8
  // Example with multiple (3) strategies
7
9
  const proposal = {
8
- choices: ['Alice', 'Bob', 'Carol', 'David']
10
+ choices: TEST_CHOICES
9
11
  };
10
12
  const strategies = [
11
13
  { name: 'ticket', network: 1, params: {} },
@@ -153,6 +155,37 @@ test.each(getScoresByStrategyTests)(
153
155
  }
154
156
  );
155
157
 
158
+ test('getScoresByStrategy should handle empty scores array from getFinalRound', () => {
159
+ // Using same votes as majority winner test where some choices have no first-place votes
160
+ const votes = [
161
+ { choice: [1, 2, 3, 4], balance: 100, scores: [100] }, // First: Alice
162
+ { choice: [1, 3, 2, 4], balance: 200, scores: [200] }, // First: Alice
163
+ { choice: [1, 4, 2, 3], balance: 150, scores: [150] }, // First: Alice
164
+ { choice: [2, 1, 3, 4], balance: 50, scores: [50] } // First: Bob
165
+ ];
166
+
167
+ const proposal = { choices: TEST_CHOICES };
168
+ const strategies = [{ name: 'ticket', network: 1, params: {} }];
169
+
170
+ const ranked = new RankedChoiceVoting(
171
+ proposal,
172
+ votes,
173
+ strategies,
174
+ example.selectedChoice
175
+ );
176
+
177
+ // This should not throw an error when reduce encounters empty scores arrays
178
+ const result = ranked.getScoresByStrategy();
179
+
180
+ // Expected: Alice gets all her votes (450), Bob gets his (50), Carol and David get 0
181
+ expect(result).toEqual([
182
+ [450], // Alice: 100 + 200 + 150
183
+ [50], // Bob: 50
184
+ [0], // Carol: no first-place votes
185
+ [0] // David: no first-place votes
186
+ ]);
187
+ });
188
+
156
189
  const getScoresTotalTests = [
157
190
  [example.proposal, example.votes, example.strategies, example.scoresTotal],
158
191
  [
@@ -178,7 +211,17 @@ test.each(getScoresTotalTests)(
178
211
 
179
212
  test.each([
180
213
  [[1, 2, 3, 4], '(1st) Alice, (2nd) Bob, (3rd) Carol, (4th) David'],
181
- [[4, 2, 3, 1], '(1st) David, (2nd) Bob, (3rd) Carol, (4th) Alice']
214
+ [[4, 2, 3, 1], '(1st) David, (2nd) Bob, (3rd) Carol, (4th) Alice'],
215
+ // Invalid choices (out of range indices)
216
+ [[5], ''], // Choice index 5 doesn't exist (only 1-4 available)
217
+ [[0], ''], // Choice index 0 is invalid (choices are 1-indexed)
218
+ [[-1], ''], // Negative choice index
219
+ [[1, 5], '(1st) Alice'], // Mix of valid (1) and invalid (5) - invalid filtered out
220
+ [[0, 2], '(1st) Bob'], // Mix of invalid (0) and valid (2) - invalid filtered out
221
+ [[5, 6, 7], ''], // All invalid indices - all filtered out
222
+ [[1, 0, 3], '(1st) Alice, (2nd) Carol'], // Valid-invalid-valid pattern - invalid filtered out
223
+ [[100], ''], // Very high invalid index
224
+ [[], ''] // Empty array
182
225
  ])('getChoiceString %s %s', (selected, expected) => {
183
226
  const ranked = new RankedChoiceVoting(
184
227
  example.proposal,
@@ -188,3 +231,128 @@ test.each([
188
231
  );
189
232
  expect(ranked.getChoiceString()).toEqual(expected);
190
233
  });
234
+
235
+ describe('isValidChoice', () => {
236
+ test.each([
237
+ [[1, 2, 3, 4], TEST_CHOICES],
238
+ [[4, 3, 2, 1], TEST_CHOICES],
239
+ [[2, 1, 4, 3], TEST_CHOICES],
240
+ [[1], ['Alice']]
241
+ ])('should accept valid ranked choice: %s', (voteChoice, proposalChoices) => {
242
+ expect(RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices)).toBe(
243
+ true
244
+ );
245
+ });
246
+
247
+ test.each([
248
+ ['not-array', TEST_CHOICES],
249
+ [null, TEST_CHOICES],
250
+ [undefined, TEST_CHOICES]
251
+ ])('should reject non-array input: %s', (voteChoice, proposalChoices) => {
252
+ expect(RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices)).toBe(
253
+ false
254
+ );
255
+ });
256
+
257
+ test.each([
258
+ [[], TEST_CHOICES],
259
+ [[], []]
260
+ ])('should reject empty choice array: %s', (voteChoice, proposalChoices) => {
261
+ expect(RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices)).toBe(
262
+ false
263
+ );
264
+ });
265
+
266
+ test.each([
267
+ [[1, 5], TEST_CHOICES],
268
+ [[0, 1, 2, 3, 4], TEST_CHOICES],
269
+ [[-1, 1, 2, 3], TEST_CHOICES],
270
+ [[100], TEST_CHOICES]
271
+ ])(
272
+ 'should reject out-of-range indices: %s',
273
+ (voteChoice, proposalChoices) => {
274
+ expect(
275
+ RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices)
276
+ ).toBe(false);
277
+ }
278
+ );
279
+
280
+ test.each([
281
+ [[1, 1, 2, 3], TEST_CHOICES],
282
+ [[1, 2, 2, 3], TEST_CHOICES],
283
+ [
284
+ [1, 1],
285
+ ['Alice', 'Bob']
286
+ ]
287
+ ])('should reject duplicate choices: %s', (voteChoice, proposalChoices) => {
288
+ expect(RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices)).toBe(
289
+ false
290
+ );
291
+ });
292
+
293
+ test.each([
294
+ [[1], TEST_CHOICES],
295
+ [[1, 2], TEST_CHOICES]
296
+ ])(
297
+ 'should reject incomplete ranking when multiple choices available: %s',
298
+ (voteChoice, proposalChoices) => {
299
+ expect(
300
+ RankedChoiceVoting.isValidChoice(voteChoice, proposalChoices)
301
+ ).toBe(false);
302
+ }
303
+ );
304
+ });
305
+
306
+ describe('getFinalRound', () => {
307
+ test('should execute instant runoff voting with multiple elimination rounds', () => {
308
+ const votes = [
309
+ { choice: [1, 2, 3, 4], balance: 100, scores: [100] }, // First: Alice
310
+ { choice: [2, 1, 3, 4], balance: 200, scores: [200] }, // First: Bob
311
+ { choice: [3, 1, 2, 4], balance: 150, scores: [150] }, // First: Carol
312
+ { choice: [4, 1, 2, 3], balance: 50, scores: [50] } // First: David
313
+ ];
314
+
315
+ const result = getFinalRound(votes);
316
+
317
+ expect(result).toEqual([
318
+ ['2', [350, [350]]], // Bob wins with 350 total (200+150 after IRV)
319
+ ['3', [150, [150]]] // Carol second with 150
320
+ ]);
321
+ });
322
+
323
+ test('should handle choices with no first-place votes', () => {
324
+ const votes = [
325
+ { choice: [1, 2, 3, 4], balance: 100, scores: [100] }, // First: Alice
326
+ { choice: [1, 3, 2, 4], balance: 200, scores: [200] }, // First: Alice
327
+ { choice: [2, 1, 3, 4], balance: 150, scores: [150] }, // First: Bob
328
+ { choice: [2, 3, 1, 4], balance: 300, scores: [300] } // First: Bob
329
+ ];
330
+
331
+ const result = getFinalRound(votes);
332
+
333
+ expect(result).toEqual([
334
+ ['2', [450, [450]]], // Bob wins with 450 (150+300)
335
+ ['1', [300, [300]]], // Alice second with 300 (100+200)
336
+ ['3', [0, []]], // Carol has no first place votes - empty scores array (our fix)
337
+ ['4', [0, []]] // David has no first place votes - empty scores array (our fix)
338
+ ]);
339
+ });
340
+
341
+ test('should declare winner in first round when candidate has majority', () => {
342
+ const votes = [
343
+ { choice: [1, 2, 3, 4], balance: 100, scores: [100] }, // First: Alice
344
+ { choice: [1, 3, 2, 4], balance: 200, scores: [200] }, // First: Alice
345
+ { choice: [1, 4, 2, 3], balance: 150, scores: [150] }, // First: Alice
346
+ { choice: [2, 1, 3, 4], balance: 50, scores: [50] } // First: Bob
347
+ ];
348
+
349
+ const result = getFinalRound(votes);
350
+
351
+ expect(result).toEqual([
352
+ ['1', [450, [450]]], // Alice wins with majority in round 1 (100+200+150)
353
+ ['2', [50, [50]]], // Bob gets remaining votes
354
+ ['3', [0, []]], // Carol has no first place votes
355
+ ['4', [0, []]] // David has no first place votes
356
+ ]);
357
+ });
358
+ });
@@ -66,7 +66,27 @@ function irv(
66
66
  );
67
67
  }
68
68
 
69
- function getFinalRound(
69
+ /**
70
+ * Runs the complete Instant Runoff Voting (IRV) algorithm and returns the final results.
71
+ *
72
+ * Executes all elimination rounds until a winner is determined or fewer than 3 candidates remain.
73
+ * Each round eliminates the candidate with the fewest votes and redistributes their votes
74
+ * to voters' next preferences.
75
+ *
76
+ * @param votes - Array of valid ranked choice votes to process
77
+ * @returns Array of tuples representing the final candidate rankings, sorted by vote count (highest first).
78
+ * Each tuple contains [candidateIndex, [totalBalance, scoresArray]] where:
79
+ * - totalBalance: Sum of voting power from all voters who support this candidate
80
+ * - scoresArray: Breakdown of that voting power by voting strategy
81
+ * The relationship: totalBalance === scoresArray.reduce((a,b) => a + b, 0)
82
+ *
83
+ * @example
84
+ * // Returns final results after IRV elimination rounds
85
+ * // [["2", [150, [60,50,40]]], ["1", [120, [70,30,20]]], ...]
86
+ * // Candidate 2 wins with 150 total voting power (60+50+40 from 3 strategies)
87
+ * // Candidate 1 has 120 total voting power (70+30+20 from 3 strategies)
88
+ */
89
+ export function getFinalRound(
70
90
  votes: RankedChoiceVote[]
71
91
  ): [string, [number, number[]]][] {
72
92
  const rounds = irv(
@@ -77,7 +97,26 @@ function getFinalRound(
77
97
  return finalRound.sortedByHighest;
78
98
  }
79
99
 
80
- function getScoresMethod(
100
+ /**
101
+ * Converts IRV final results into a simple array of scores indexed by proposal choice order.
102
+ *
103
+ * Takes the ranked results from getFinalRound() (sorted by winner) and transforms them
104
+ * into an array where each position corresponds to the original proposal choice index.
105
+ * This allows easy lookup of any candidate's final vote total by their position in the proposal.
106
+ *
107
+ * @param votes - Array of valid ranked choice votes to process
108
+ * @param proposal - Proposal object containing the choices array
109
+ * @returns Array of total voting power for each choice, indexed by proposal order.
110
+ * Position 0 = first choice's votes, position 1 = second choice's votes, etc.
111
+ *
112
+ * @example
113
+ * // proposal.choices = ['Alice', 'Bob', 'Carol', 'David']
114
+ * // After IRV: Bob won (150), David 2nd (120), Alice 3rd (100), Carol 4th (80)
115
+ * // Returns: [100, 150, 80, 120]
116
+ * // ↑ ↑ ↑ ↑
117
+ * // Alice Bob Carol David (proposal order)
118
+ */
119
+ function getFinalScoresByChoice(
81
120
  votes: RankedChoiceVote[],
82
121
  proposal: { choices: string[] }
83
122
  ) {
@@ -134,7 +173,7 @@ export default class RankedChoiceVoting {
134
173
  }
135
174
 
136
175
  getScores(): number[] {
137
- return getScoresMethod(this.getValidVotes(), this.proposal);
176
+ return getFinalScoresByChoice(this.getValidVotes(), this.proposal);
138
177
  }
139
178
 
140
179
  getScoresByStrategy(): number[][] {
@@ -148,16 +187,57 @@ export default class RankedChoiceVoting {
148
187
  );
149
188
  }
150
189
 
190
+ /**
191
+ * Returns the total voting power from all submitted votes, including invalid ones.
192
+ *
193
+ * This method sums the balance (voting power) from ALL votes submitted to the proposal,
194
+ * regardless of whether they have valid choice arrays. This is useful for calculating
195
+ * total participation, quorum requirements, and percentage of total voting power.
196
+ *
197
+ * Note: This differs from IRV final results which only include valid votes. Invalid votes
198
+ * are excluded from IRV calculations but their voting power is still counted here for
199
+ * participation metrics.
200
+ *
201
+ * @returns Total voting power from all votes (valid + invalid)
202
+ *
203
+ * @example
204
+ * // votes = [
205
+ * // { choice: [1,2,3,4], balance: 1000 }, // Valid
206
+ * // { choice: [1,5,2], balance: 500 }, // Invalid (index 5)
207
+ * // { choice: [2,1,4,3], balance: 750 } // Valid
208
+ * // ]
209
+ * // Returns: 2250 (includes invalid vote's 500 balance)
210
+ */
151
211
  getScoresTotal(): number {
152
212
  return this.votes.reduce((a, b: any) => a + b.balance, 0);
153
213
  }
154
214
 
215
+ /**
216
+ * Converts the selected choice indices into a human-readable string representation.
217
+ *
218
+ * Note: This method supports partial ranking where not all available choices
219
+ * need to be selected. The ordinal positions (1st, 2nd, etc.) reflect the
220
+ * order of valid selections only. Invalid choice indices are filtered out.
221
+ *
222
+ * @returns A formatted string showing the ranked choices with ordinal positions.
223
+ * Only valid choices are included, invalid indices are silently ignored.
224
+ *
225
+ * @example
226
+ * // With choices ['Alice', 'Bob', 'Carol', 'David'] and selected [1, 3, 2]
227
+ * // Returns: "(1st) Alice, (2nd) Carol, (3rd) Bob"
228
+ *
229
+ * @example
230
+ * // Partial ranking with choices ['Alice', 'Bob', 'Carol', 'David'] and selected [4, 1]
231
+ * // Returns: "(1st) David, (2nd) Alice"
232
+ *
233
+ * @example
234
+ * // With invalid choice index 5 in selected [1, 5]
235
+ * // Returns: "(1st) Alice" - invalid choice 5 is filtered out
236
+ */
155
237
  getChoiceString(): string {
156
238
  return this.selected
157
- .map((choice) => {
158
- if (this.proposal.choices[choice - 1])
159
- return this.proposal.choices[choice - 1];
160
- })
239
+ .map((choice) => this.proposal.choices[choice - 1])
240
+ .filter(Boolean)
161
241
  .map((el, i) => `(${getNumberWithOrdinal(i + 1)}) ${el}`)
162
242
  .join(', ');
163
243
  }