@sip-protocol/sdk 0.6.0 → 0.6.2

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.
Files changed (63) hide show
  1. package/README.md +58 -0
  2. package/dist/browser.d.mts +4 -4
  3. package/dist/browser.d.ts +4 -4
  4. package/dist/browser.js +2752 -448
  5. package/dist/browser.mjs +31 -1
  6. package/dist/chunk-7QZPORY5.mjs +15604 -0
  7. package/dist/chunk-C2NPCUAJ.mjs +17010 -0
  8. package/dist/chunk-FCVLFUIC.mjs +16699 -0
  9. package/dist/chunk-G5UHXECN.mjs +16340 -0
  10. package/dist/chunk-GEDEIZHJ.mjs +16798 -0
  11. package/dist/chunk-GOOEOAMV.mjs +17026 -0
  12. package/dist/chunk-MTNYSNR7.mjs +16269 -0
  13. package/dist/chunk-O5PIB2EA.mjs +16698 -0
  14. package/dist/chunk-PCFM7FQO.mjs +17010 -0
  15. package/dist/chunk-QK464ARC.mjs +16946 -0
  16. package/dist/chunk-VNBMNGC3.mjs +16698 -0
  17. package/dist/chunk-W5TUELDQ.mjs +16947 -0
  18. package/dist/index-CD_zShu-.d.ts +10870 -0
  19. package/dist/index-CQBYdLYy.d.mts +10976 -0
  20. package/dist/index-Cg9TYEPv.d.mts +11321 -0
  21. package/dist/index-CqZJOO8C.d.mts +11323 -0
  22. package/dist/index-CywN9Bnp.d.ts +11321 -0
  23. package/dist/index-DHy5ZjCD.d.ts +10976 -0
  24. package/dist/index-DfsVsmxu.d.ts +11323 -0
  25. package/dist/index-ObjwyVDX.d.mts +10870 -0
  26. package/dist/index-m0xbSfmT.d.mts +11318 -0
  27. package/dist/index-rWLEgvhN.d.ts +11318 -0
  28. package/dist/index.d.mts +3 -3
  29. package/dist/index.d.ts +3 -3
  30. package/dist/index.js +2737 -427
  31. package/dist/index.mjs +31 -1
  32. package/dist/noir-DKfEzWy9.d.mts +482 -0
  33. package/dist/noir-DKfEzWy9.d.ts +482 -0
  34. package/dist/proofs/noir.d.mts +1 -1
  35. package/dist/proofs/noir.d.ts +1 -1
  36. package/dist/proofs/noir.js +12 -3
  37. package/dist/proofs/noir.mjs +12 -3
  38. package/package.json +16 -14
  39. package/src/adapters/near-intents.ts +13 -3
  40. package/src/auction/index.ts +20 -0
  41. package/src/auction/sealed-bid.ts +1037 -0
  42. package/src/compliance/derivation.ts +13 -3
  43. package/src/compliance/reports.ts +5 -4
  44. package/src/cosmos/ibc-stealth.ts +2 -2
  45. package/src/cosmos/stealth.ts +2 -2
  46. package/src/governance/index.ts +19 -0
  47. package/src/governance/private-vote.ts +1116 -0
  48. package/src/index.ts +50 -2
  49. package/src/intent.ts +145 -8
  50. package/src/nft/index.ts +27 -0
  51. package/src/nft/private-nft.ts +811 -0
  52. package/src/proofs/browser-utils.ts +1 -7
  53. package/src/proofs/noir.ts +34 -7
  54. package/src/settlement/backends/direct-chain.ts +14 -3
  55. package/src/stealth.ts +31 -13
  56. package/src/types/browser.d.ts +67 -0
  57. package/src/validation.ts +4 -2
  58. package/src/wallet/bitcoin/adapter.ts +159 -15
  59. package/src/wallet/bitcoin/types.ts +340 -15
  60. package/src/wallet/cosmos/mock.ts +16 -12
  61. package/src/wallet/hardware/ledger.ts +82 -12
  62. package/src/wallet/hardware/types.ts +2 -0
  63. package/LICENSE +0 -21
@@ -0,0 +1,1116 @@
1
+ /**
2
+ * Private Voting for SIP Protocol
3
+ *
4
+ * Provides encrypted voting with timelock or committee key encryption.
5
+ * Enables private DAO governance where votes are hidden during voting period
6
+ * and revealed after deadline or by committee decision.
7
+ *
8
+ * ## Security Properties
9
+ * - **Confidentiality**: Votes encrypted until revelation
10
+ * - **Integrity**: Authentication tag prevents tampering
11
+ * - **Verifiability**: Revealed votes can be verified against encrypted versions
12
+ *
13
+ * ## Use Cases
14
+ * - Private DAO governance proposals
15
+ * - Confidential treasury spending votes
16
+ * - Timelock-based vote revelation
17
+ * - Committee-controlled vote disclosure
18
+ */
19
+
20
+ import type { HexString } from '@sip-protocol/types'
21
+ import { sha256 } from '@noble/hashes/sha256'
22
+ import { hkdf } from '@noble/hashes/hkdf'
23
+ import { bytesToHex, hexToBytes, randomBytes, utf8ToBytes } from '@noble/hashes/utils'
24
+ import { xchacha20poly1305 } from '@noble/ciphers/chacha.js'
25
+ import { ValidationError, CryptoError, ErrorCode } from '../errors'
26
+ import { secureWipe } from '../secure-memory'
27
+ import { isValidHex } from '../validation'
28
+ import { commit, addCommitments, generateBlinding } from '../commitment'
29
+
30
+ /**
31
+ * Domain separation for vote encryption key derivation
32
+ */
33
+ const VOTE_ENCRYPTION_DOMAIN = 'SIP-PRIVATE-VOTE-ENCRYPTION-V1'
34
+
35
+ /**
36
+ * XChaCha20-Poly1305 nonce size (24 bytes)
37
+ */
38
+ const NONCE_SIZE = 24
39
+
40
+ /**
41
+ * Maximum size for vote data (1MB)
42
+ * Prevents DoS attacks via large payloads
43
+ */
44
+ const MAX_VOTE_DATA_SIZE = 1024 * 1024
45
+
46
+ /**
47
+ * Encrypted vote data
48
+ */
49
+ export interface EncryptedVote {
50
+ /** The encrypted vote data (includes authentication tag) */
51
+ ciphertext: HexString
52
+ /** Nonce used for encryption (needed for decryption) */
53
+ nonce: HexString
54
+ /** Hash of the encryption key used (for key identification) */
55
+ encryptionKeyHash: HexString
56
+ /** Proposal ID this vote is for */
57
+ proposalId: string
58
+ /** Voter's identifier (public key or address) */
59
+ voter: string
60
+ /** Timestamp when vote was cast */
61
+ timestamp: number
62
+ }
63
+
64
+ /**
65
+ * Revealed vote data
66
+ */
67
+ export interface RevealedVote {
68
+ /** Proposal ID */
69
+ proposalId: string
70
+ /** Vote choice (e.g., 0 = no, 1 = yes, 2 = abstain) */
71
+ choice: number
72
+ /** Voting weight/power */
73
+ weight: bigint
74
+ /** Voter's identifier */
75
+ voter: string
76
+ /** Timestamp when vote was cast */
77
+ timestamp: number
78
+ /** The encrypted vote this was revealed from */
79
+ encryptedVote: EncryptedVote
80
+ }
81
+
82
+ /**
83
+ * Encrypted tally of aggregated votes
84
+ *
85
+ * Uses Pedersen commitment homomorphism to sum votes without decrypting
86
+ * individual votes. Each choice gets a separate commitment.
87
+ */
88
+ export interface EncryptedTally {
89
+ /** Proposal ID this tally is for */
90
+ proposalId: string
91
+ /** Map of choice -> aggregated commitment (choice as string key) */
92
+ tallies: Record<string, HexString>
93
+ /**
94
+ * Encrypted blinding factors for each choice (committee holds decryption key)
95
+ * This is encrypted with XChaCha20-Poly1305 using committee key
96
+ */
97
+ encryptedBlindings: Record<string, { ciphertext: HexString; nonce: HexString }>
98
+ /** Number of votes included in this tally */
99
+ voteCount: number
100
+ /** Timestamp when tally was computed */
101
+ timestamp: number
102
+ }
103
+
104
+ /**
105
+ * Final revealed tally results
106
+ */
107
+ export interface TallyResult {
108
+ /** Proposal ID */
109
+ proposalId: string
110
+ /** Map of choice -> total weight (choice as string key) */
111
+ results: Record<string, bigint>
112
+ /** Total number of votes tallied */
113
+ voteCount: number
114
+ /** Timestamp when tally was revealed */
115
+ timestamp: number
116
+ /** The encrypted tally this was revealed from */
117
+ encryptedTally: EncryptedTally
118
+ }
119
+
120
+ /**
121
+ * Decryption share from a committee member
122
+ *
123
+ * In threshold cryptography, each committee member contributes a share
124
+ * to collectively decrypt the tally.
125
+ */
126
+ export interface DecryptionShare {
127
+ /** Committee member's identifier */
128
+ memberId: string
129
+ /** The decryption share data */
130
+ share: HexString
131
+ /** Signature or proof that this share is valid (future use) */
132
+ proof?: HexString
133
+ }
134
+
135
+ /**
136
+ * Parameters for casting a vote
137
+ */
138
+ export interface CastVoteParams {
139
+ /** Proposal ID to vote on */
140
+ proposalId: string
141
+ /** Vote choice (e.g., 0 = no, 1 = yes, 2 = abstain) */
142
+ choice: number
143
+ /** Voting weight/power */
144
+ weight: bigint
145
+ /** Encryption key (timelock or committee key) */
146
+ encryptionKey: string
147
+ /** Optional voter identifier (defaults to 'anonymous') */
148
+ voter?: string
149
+ }
150
+
151
+ /**
152
+ * Vote data that gets encrypted
153
+ */
154
+ interface VoteData {
155
+ proposalId: string
156
+ choice: number
157
+ weight: string // bigint serialized as string
158
+ voter: string
159
+ timestamp: number
160
+ }
161
+
162
+ /**
163
+ * Private voting implementation with encryption
164
+ *
165
+ * Enables confidential voting where votes are encrypted during the voting period
166
+ * and can be revealed later with the appropriate decryption key.
167
+ *
168
+ * @example Cast encrypted vote
169
+ * ```typescript
170
+ * const voting = new PrivateVoting()
171
+ *
172
+ * // Committee derives a shared encryption key
173
+ * const encryptionKey = deriveCommitteeKey(...)
174
+ *
175
+ * const encryptedVote = voting.castVote({
176
+ * proposalId: 'proposal-123',
177
+ * choice: 1, // yes
178
+ * weight: 1000n,
179
+ * encryptionKey,
180
+ * })
181
+ *
182
+ * // Store encrypted vote on-chain or in database
183
+ * await storeVote(encryptedVote)
184
+ * ```
185
+ *
186
+ * @example Reveal votes after deadline
187
+ * ```typescript
188
+ * // After voting period ends, reveal votes
189
+ * const decryptionKey = unlockTimelockKey(...)
190
+ *
191
+ * const revealed = voting.revealVote(
192
+ * encryptedVote,
193
+ * decryptionKey
194
+ * )
195
+ *
196
+ * console.log(`Vote for proposal ${revealed.proposalId}:`)
197
+ * console.log(` Choice: ${revealed.choice}`)
198
+ * console.log(` Weight: ${revealed.weight}`)
199
+ * ```
200
+ *
201
+ * @example Committee-controlled revelation
202
+ * ```typescript
203
+ * // Committee member reveals vote when authorized
204
+ * const committeeKey = getCommitteeMemberKey(memberId)
205
+ *
206
+ * try {
207
+ * const revealed = voting.revealVote(encryptedVote, committeeKey)
208
+ * // Process revealed vote
209
+ * } catch (e) {
210
+ * console.error('Unauthorized revelation attempt')
211
+ * }
212
+ * ```
213
+ */
214
+ export class PrivateVoting {
215
+ /**
216
+ * Cast an encrypted vote
217
+ *
218
+ * Encrypts vote data using XChaCha20-Poly1305 authenticated encryption.
219
+ * The encryption key is typically derived from:
220
+ * - Timelock encryption (reveals after specific time)
221
+ * - Committee multisig key (reveals by committee decision)
222
+ * - Threshold scheme (reveals when threshold reached)
223
+ *
224
+ * @param params - Vote casting parameters
225
+ * @returns Encrypted vote that can be stored publicly
226
+ *
227
+ * @throws {ValidationError} If parameters are invalid
228
+ *
229
+ * @example
230
+ * ```typescript
231
+ * const voting = new PrivateVoting()
232
+ *
233
+ * const encryptedVote = voting.castVote({
234
+ * proposalId: 'prop-001',
235
+ * choice: 1,
236
+ * weight: 100n,
237
+ * encryptionKey: '0xabc...',
238
+ * })
239
+ * ```
240
+ */
241
+ castVote(params: CastVoteParams): EncryptedVote {
242
+ // Validate parameters
243
+ this.validateCastVoteParams(params)
244
+
245
+ const { proposalId, choice, weight, encryptionKey, voter = 'anonymous' } = params
246
+
247
+ // Derive encryption key from provided key
248
+ const derivedKey = this.deriveEncryptionKey(encryptionKey, proposalId)
249
+
250
+ try {
251
+ // Generate random nonce (24 bytes for XChaCha20)
252
+ const nonce = randomBytes(NONCE_SIZE)
253
+
254
+ // Prepare vote data
255
+ const voteData: VoteData = {
256
+ proposalId,
257
+ choice,
258
+ weight: weight.toString(),
259
+ voter,
260
+ timestamp: Date.now(),
261
+ }
262
+
263
+ // Serialize to JSON
264
+ const plaintext = utf8ToBytes(JSON.stringify(voteData))
265
+
266
+ // Encrypt with XChaCha20-Poly1305
267
+ const cipher = xchacha20poly1305(derivedKey, nonce)
268
+ const ciphertext = cipher.encrypt(plaintext)
269
+
270
+ // Compute encryption key hash for identification
271
+ const keyHash = sha256(hexToBytes(encryptionKey.slice(2)))
272
+
273
+ return {
274
+ ciphertext: `0x${bytesToHex(ciphertext)}` as HexString,
275
+ nonce: `0x${bytesToHex(nonce)}` as HexString,
276
+ encryptionKeyHash: `0x${bytesToHex(keyHash)}` as HexString,
277
+ proposalId,
278
+ voter,
279
+ timestamp: voteData.timestamp,
280
+ }
281
+ } finally {
282
+ // Securely wipe derived key after use
283
+ secureWipe(derivedKey)
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Reveal an encrypted vote
289
+ *
290
+ * Decrypts vote data using the provided decryption key. The key must match
291
+ * the original encryption key used when casting the vote.
292
+ *
293
+ * @param vote - Encrypted vote to reveal
294
+ * @param decryptionKey - Key to decrypt the vote (must match encryption key)
295
+ * @returns Revealed vote data
296
+ *
297
+ * @throws {CryptoError} If decryption fails (wrong key or tampered data)
298
+ * @throws {ValidationError} If vote data is invalid
299
+ *
300
+ * @example
301
+ * ```typescript
302
+ * const voting = new PrivateVoting()
303
+ *
304
+ * try {
305
+ * const revealed = voting.revealVote(encryptedVote, decryptionKey)
306
+ * console.log(`Choice: ${revealed.choice}, Weight: ${revealed.weight}`)
307
+ * } catch (e) {
308
+ * console.error('Failed to reveal vote:', e.message)
309
+ * }
310
+ * ```
311
+ */
312
+ revealVote(
313
+ vote: EncryptedVote,
314
+ decryptionKey: string,
315
+ ): RevealedVote {
316
+ // Validate encrypted vote
317
+ this.validateEncryptedVote(vote)
318
+
319
+ // Validate decryption key
320
+ if (!isValidHex(decryptionKey)) {
321
+ throw new ValidationError(
322
+ 'decryptionKey must be a valid hex string with 0x prefix',
323
+ 'decryptionKey',
324
+ undefined,
325
+ ErrorCode.INVALID_KEY
326
+ )
327
+ }
328
+
329
+ // Derive encryption key (same process as encryption)
330
+ const derivedKey = this.deriveEncryptionKey(decryptionKey, vote.proposalId)
331
+
332
+ try {
333
+ // Verify key hash matches (optional but helpful error message)
334
+ const keyHash = sha256(hexToBytes(decryptionKey.slice(2)))
335
+ const expectedKeyHash = `0x${bytesToHex(keyHash)}` as HexString
336
+
337
+ if (vote.encryptionKeyHash !== expectedKeyHash) {
338
+ throw new CryptoError(
339
+ 'Decryption key hash mismatch - this key cannot decrypt this vote',
340
+ ErrorCode.DECRYPTION_FAILED,
341
+ { operation: 'revealVote' }
342
+ )
343
+ }
344
+
345
+ // Parse nonce and ciphertext
346
+ const nonceHex = vote.nonce.startsWith('0x') ? vote.nonce.slice(2) : vote.nonce
347
+ const nonce = hexToBytes(nonceHex)
348
+
349
+ const ciphertextHex = vote.ciphertext.startsWith('0x')
350
+ ? vote.ciphertext.slice(2)
351
+ : vote.ciphertext
352
+ const ciphertext = hexToBytes(ciphertextHex)
353
+
354
+ // Decrypt with XChaCha20-Poly1305
355
+ const cipher = xchacha20poly1305(derivedKey, nonce)
356
+ let plaintext: Uint8Array
357
+
358
+ try {
359
+ plaintext = cipher.decrypt(ciphertext)
360
+ } catch (e) {
361
+ throw new CryptoError(
362
+ 'Decryption failed - authentication tag verification failed. ' +
363
+ 'Either the decryption key is incorrect or the vote has been tampered with.',
364
+ ErrorCode.DECRYPTION_FAILED,
365
+ {
366
+ cause: e instanceof Error ? e : undefined,
367
+ operation: 'revealVote',
368
+ }
369
+ )
370
+ }
371
+
372
+ // Parse JSON
373
+ const textDecoder = new TextDecoder()
374
+ const jsonString = textDecoder.decode(plaintext)
375
+
376
+ // Validate size
377
+ if (jsonString.length > MAX_VOTE_DATA_SIZE) {
378
+ throw new ValidationError(
379
+ `decrypted vote data exceeds maximum size limit (${MAX_VOTE_DATA_SIZE} bytes)`,
380
+ 'voteData',
381
+ { received: jsonString.length, max: MAX_VOTE_DATA_SIZE },
382
+ ErrorCode.INVALID_INPUT
383
+ )
384
+ }
385
+
386
+ // Parse and validate vote data
387
+ let voteData: VoteData
388
+ try {
389
+ voteData = JSON.parse(jsonString) as VoteData
390
+ } catch (e) {
391
+ if (e instanceof SyntaxError) {
392
+ throw new CryptoError(
393
+ 'Decryption succeeded but vote data is malformed JSON',
394
+ ErrorCode.DECRYPTION_FAILED,
395
+ { cause: e, operation: 'revealVote' }
396
+ )
397
+ }
398
+ throw e
399
+ }
400
+
401
+ // Validate required fields
402
+ if (
403
+ typeof voteData.proposalId !== 'string' ||
404
+ typeof voteData.choice !== 'number' ||
405
+ typeof voteData.weight !== 'string' ||
406
+ typeof voteData.voter !== 'string' ||
407
+ typeof voteData.timestamp !== 'number'
408
+ ) {
409
+ throw new ValidationError(
410
+ 'invalid vote data format',
411
+ 'voteData',
412
+ { received: voteData },
413
+ ErrorCode.INVALID_INPUT
414
+ )
415
+ }
416
+
417
+ // Verify proposal ID matches
418
+ if (voteData.proposalId !== vote.proposalId) {
419
+ throw new ValidationError(
420
+ 'proposal ID mismatch between encrypted vote and decrypted data',
421
+ 'proposalId',
422
+ { encrypted: vote.proposalId, decrypted: voteData.proposalId },
423
+ ErrorCode.INVALID_INPUT
424
+ )
425
+ }
426
+
427
+ // Parse weight from string
428
+ let weight: bigint
429
+ try {
430
+ weight = BigInt(voteData.weight)
431
+ } catch (e) {
432
+ throw new ValidationError(
433
+ 'invalid weight value',
434
+ 'weight',
435
+ { received: voteData.weight },
436
+ ErrorCode.INVALID_AMOUNT
437
+ )
438
+ }
439
+
440
+ return {
441
+ proposalId: voteData.proposalId,
442
+ choice: voteData.choice,
443
+ weight,
444
+ voter: voteData.voter,
445
+ timestamp: voteData.timestamp,
446
+ encryptedVote: vote,
447
+ }
448
+ } finally {
449
+ // Securely wipe derived key after use
450
+ secureWipe(derivedKey)
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Derive encryption key from provided key using HKDF
456
+ *
457
+ * Uses HKDF-SHA256 with domain separation for security.
458
+ * Incorporates proposal ID for key binding.
459
+ *
460
+ * @param key - Source encryption key
461
+ * @param proposalId - Proposal ID for key binding
462
+ * @returns 32-byte derived encryption key (caller must wipe after use)
463
+ */
464
+ private deriveEncryptionKey(key: string, proposalId: string): Uint8Array {
465
+ // Extract the raw key bytes (remove 0x prefix if present)
466
+ const keyHex = key.startsWith('0x') ? key.slice(2) : key
467
+ const keyBytes = hexToBytes(keyHex)
468
+
469
+ try {
470
+ // Use HKDF to derive a proper encryption key
471
+ // HKDF(SHA256, ikm=key, salt=domain, info=proposalId, length=32)
472
+ const salt = utf8ToBytes(VOTE_ENCRYPTION_DOMAIN)
473
+ const info = utf8ToBytes(proposalId)
474
+
475
+ return hkdf(sha256, keyBytes, salt, info, 32)
476
+ } finally {
477
+ // Securely wipe source key bytes
478
+ secureWipe(keyBytes)
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Validate cast vote parameters
484
+ */
485
+ private validateCastVoteParams(params: CastVoteParams): void {
486
+ const { proposalId, choice, weight, encryptionKey, voter } = params
487
+
488
+ // Validate proposal ID
489
+ if (typeof proposalId !== 'string' || proposalId.length === 0) {
490
+ throw new ValidationError(
491
+ 'proposalId must be a non-empty string',
492
+ 'proposalId',
493
+ undefined,
494
+ ErrorCode.MISSING_REQUIRED
495
+ )
496
+ }
497
+
498
+ // Validate choice
499
+ if (typeof choice !== 'number' || !Number.isInteger(choice) || choice < 0) {
500
+ throw new ValidationError(
501
+ 'choice must be a non-negative integer',
502
+ 'choice',
503
+ { received: choice },
504
+ ErrorCode.INVALID_INPUT
505
+ )
506
+ }
507
+
508
+ // Validate weight
509
+ if (typeof weight !== 'bigint') {
510
+ throw new ValidationError(
511
+ 'weight must be a bigint',
512
+ 'weight',
513
+ { received: typeof weight },
514
+ ErrorCode.INVALID_AMOUNT
515
+ )
516
+ }
517
+
518
+ if (weight < 0n) {
519
+ throw new ValidationError(
520
+ 'weight must be non-negative',
521
+ 'weight',
522
+ { received: weight.toString() },
523
+ ErrorCode.INVALID_AMOUNT
524
+ )
525
+ }
526
+
527
+ // Validate encryption key
528
+ if (!isValidHex(encryptionKey)) {
529
+ throw new ValidationError(
530
+ 'encryptionKey must be a valid hex string with 0x prefix',
531
+ 'encryptionKey',
532
+ undefined,
533
+ ErrorCode.INVALID_KEY
534
+ )
535
+ }
536
+
537
+ // Validate voter (optional)
538
+ if (voter !== undefined && typeof voter !== 'string') {
539
+ throw new ValidationError(
540
+ 'voter must be a string',
541
+ 'voter',
542
+ { received: typeof voter },
543
+ ErrorCode.INVALID_INPUT
544
+ )
545
+ }
546
+ }
547
+
548
+ /**
549
+ * Tally votes homomorphically
550
+ *
551
+ * Aggregates encrypted votes by summing Pedersen commitments for each choice.
552
+ * Individual votes remain hidden - only the final tally can be revealed.
553
+ *
554
+ * This leverages the homomorphic property of Pedersen commitments:
555
+ * C(v1) + C(v2) = C(v1 + v2) when blindings are properly tracked.
556
+ *
557
+ * **Note:** In this simplified implementation, we reveal individual votes to
558
+ * compute commitments for each choice. A full production implementation would
559
+ * use commitments directly from votes without decryption.
560
+ *
561
+ * @param votes - Array of encrypted votes to tally
562
+ * @param decryptionKey - Key to decrypt votes (committee key)
563
+ * @returns Encrypted tally with aggregated commitments per choice
564
+ *
565
+ * @throws {ValidationError} If votes array is empty or has inconsistent proposal IDs
566
+ * @throws {CryptoError} If decryption fails
567
+ *
568
+ * @example
569
+ * ```typescript
570
+ * const voting = new PrivateVoting()
571
+ * const encryptionKey = generateRandomBytes(32)
572
+ *
573
+ * // Cast multiple votes
574
+ * const votes = [
575
+ * voting.castVote({ proposalId: 'p1', choice: 0, weight: 100n, encryptionKey }),
576
+ * voting.castVote({ proposalId: 'p1', choice: 1, weight: 200n, encryptionKey }),
577
+ * voting.castVote({ proposalId: 'p1', choice: 0, weight: 150n, encryptionKey }),
578
+ * ]
579
+ *
580
+ * // Tally homomorphically
581
+ * const tally = voting.tallyVotes(votes, encryptionKey)
582
+ * // tally contains: choice 0 -> commitment(250), choice 1 -> commitment(200)
583
+ * ```
584
+ */
585
+ tallyVotes(votes: EncryptedVote[], decryptionKey: string): EncryptedTally {
586
+ // Validate inputs
587
+ if (!Array.isArray(votes)) {
588
+ throw new ValidationError(
589
+ 'votes must be an array',
590
+ 'votes',
591
+ undefined,
592
+ ErrorCode.INVALID_INPUT
593
+ )
594
+ }
595
+
596
+ if (votes.length === 0) {
597
+ throw new ValidationError(
598
+ 'votes array cannot be empty',
599
+ 'votes',
600
+ undefined,
601
+ ErrorCode.INVALID_INPUT
602
+ )
603
+ }
604
+
605
+ // Validate all votes are for the same proposal
606
+ const proposalId = votes[0].proposalId
607
+ for (const vote of votes) {
608
+ if (vote.proposalId !== proposalId) {
609
+ throw new ValidationError(
610
+ 'all votes must be for the same proposal',
611
+ 'votes',
612
+ { expected: proposalId, received: vote.proposalId },
613
+ ErrorCode.INVALID_INPUT
614
+ )
615
+ }
616
+ }
617
+
618
+ // Validate decryption key
619
+ if (!isValidHex(decryptionKey)) {
620
+ throw new ValidationError(
621
+ 'decryptionKey must be a valid hex string with 0x prefix',
622
+ 'decryptionKey',
623
+ undefined,
624
+ ErrorCode.INVALID_KEY
625
+ )
626
+ }
627
+
628
+ // Decrypt all votes and group by choice
629
+ const votesByChoice: Record<string, bigint[]> = {}
630
+
631
+ for (const encryptedVote of votes) {
632
+ const revealed = this.revealVote(encryptedVote, decryptionKey)
633
+ const choiceKey = revealed.choice.toString()
634
+
635
+ if (!votesByChoice[choiceKey]) {
636
+ votesByChoice[choiceKey] = []
637
+ }
638
+ votesByChoice[choiceKey].push(revealed.weight)
639
+ }
640
+
641
+ // Create commitments for each choice's total
642
+ const tallies: Record<string, HexString> = {}
643
+ const blindings: Record<string, HexString> = {}
644
+
645
+ for (const [choice, weights] of Object.entries(votesByChoice)) {
646
+ // Sum weights for this choice
647
+ const totalWeight = weights.reduce((sum, w) => sum + w, 0n)
648
+
649
+ // Create Pedersen commitment to the total
650
+ // In production, you'd aggregate commitments directly without revealing
651
+ // Here we commit to the sum for simplicity
652
+ const { commitment, blinding } = commit(totalWeight, hexToBytes(generateBlinding().slice(2)))
653
+ tallies[choice] = commitment
654
+ blindings[choice] = blinding
655
+ }
656
+
657
+ // Encrypt the blinding factors with the decryption key
658
+ // This allows the committee to later reveal the tally
659
+ const encryptedBlindings: Record<string, { ciphertext: HexString; nonce: HexString }> = {}
660
+
661
+ for (const [choice, blinding] of Object.entries(blindings)) {
662
+ // Generate nonce for encryption
663
+ const nonce = randomBytes(NONCE_SIZE)
664
+
665
+ // Derive encryption key
666
+ const derivedKey = this.deriveEncryptionKey(decryptionKey, `${proposalId}-tally-${choice}`)
667
+
668
+ try {
669
+ // Encrypt the blinding factor
670
+ const cipher = xchacha20poly1305(derivedKey, nonce)
671
+ const blindingBytes = hexToBytes(blinding.slice(2))
672
+ const ciphertext = cipher.encrypt(blindingBytes)
673
+
674
+ encryptedBlindings[choice] = {
675
+ ciphertext: `0x${bytesToHex(ciphertext)}` as HexString,
676
+ nonce: `0x${bytesToHex(nonce)}` as HexString,
677
+ }
678
+ } finally {
679
+ secureWipe(derivedKey)
680
+ }
681
+ }
682
+
683
+ return {
684
+ proposalId,
685
+ tallies,
686
+ encryptedBlindings,
687
+ voteCount: votes.length,
688
+ timestamp: Date.now(),
689
+ }
690
+ }
691
+
692
+ /**
693
+ * Reveal the final tally using threshold decryption
694
+ *
695
+ * In a full threshold cryptography implementation, t-of-n committee members
696
+ * would each provide a decryption share. When enough shares are collected,
697
+ * the tally can be revealed.
698
+ *
699
+ * **Note:** This simplified implementation uses a single decryption key.
700
+ * A production system would implement proper threshold secret sharing
701
+ * (e.g., Shamir's Secret Sharing) for committee-based decryption.
702
+ *
703
+ * @param tally - Encrypted tally to reveal
704
+ * @param decryptionShares - Decryption shares from committee members
705
+ * @returns Final tally results with revealed vote counts per choice
706
+ *
707
+ * @throws {ValidationError} If tally is invalid or insufficient shares provided
708
+ * @throws {CryptoError} If threshold reconstruction fails
709
+ *
710
+ * @example
711
+ * ```typescript
712
+ * const voting = new PrivateVoting()
713
+ *
714
+ * // After tallying...
715
+ * const shares = [
716
+ * { memberId: 'member1', share: '0xabc...' },
717
+ * { memberId: 'member2', share: '0xdef...' },
718
+ * { memberId: 'member3', share: '0x123...' },
719
+ * ]
720
+ *
721
+ * const results = voting.revealTally(encryptedTally, shares)
722
+ * console.log(results.results) // { "0": 250n, "1": 200n }
723
+ * ```
724
+ */
725
+ revealTally(
726
+ tally: EncryptedTally,
727
+ decryptionShares: DecryptionShare[],
728
+ ): TallyResult {
729
+ // Validate tally
730
+ this.validateEncryptedTally(tally)
731
+
732
+ // Validate decryption shares
733
+ if (!Array.isArray(decryptionShares)) {
734
+ throw new ValidationError(
735
+ 'decryptionShares must be an array',
736
+ 'decryptionShares',
737
+ undefined,
738
+ ErrorCode.INVALID_INPUT
739
+ )
740
+ }
741
+
742
+ if (decryptionShares.length === 0) {
743
+ throw new ValidationError(
744
+ 'must provide at least one decryption share',
745
+ 'decryptionShares',
746
+ undefined,
747
+ ErrorCode.INVALID_INPUT
748
+ )
749
+ }
750
+
751
+ // Validate each share
752
+ for (const share of decryptionShares) {
753
+ if (!share || typeof share !== 'object') {
754
+ throw new ValidationError(
755
+ 'each decryption share must be an object',
756
+ 'decryptionShares',
757
+ undefined,
758
+ ErrorCode.INVALID_INPUT
759
+ )
760
+ }
761
+
762
+ if (typeof share.memberId !== 'string' || share.memberId.length === 0) {
763
+ throw new ValidationError(
764
+ 'each share must have a non-empty memberId',
765
+ 'decryptionShares.memberId',
766
+ undefined,
767
+ ErrorCode.INVALID_INPUT
768
+ )
769
+ }
770
+
771
+ if (!isValidHex(share.share)) {
772
+ throw new ValidationError(
773
+ 'each share.share must be a valid hex string',
774
+ 'decryptionShares.share',
775
+ undefined,
776
+ ErrorCode.INVALID_ENCRYPTED_DATA
777
+ )
778
+ }
779
+ }
780
+
781
+ // In this simplified implementation, we reconstruct the decryption key
782
+ // from the shares. In production, this would use Shamir's Secret Sharing
783
+ // or a proper threshold scheme.
784
+ //
785
+ // For now, we'll use XOR reconstruction (simplified 1-of-n threshold)
786
+ let reconstructedKey: Uint8Array | null = null
787
+
788
+ try {
789
+ // Simple XOR reconstruction (toy example)
790
+ reconstructedKey = hexToBytes(decryptionShares[0].share.slice(2))
791
+
792
+ for (let i = 1; i < decryptionShares.length; i++) {
793
+ const shareBytes = hexToBytes(decryptionShares[i].share.slice(2))
794
+
795
+ if (shareBytes.length !== reconstructedKey.length) {
796
+ throw new ValidationError(
797
+ 'all decryption shares must have the same length',
798
+ 'decryptionShares',
799
+ undefined,
800
+ ErrorCode.INVALID_INPUT
801
+ )
802
+ }
803
+
804
+ for (let j = 0; j < reconstructedKey.length; j++) {
805
+ reconstructedKey[j] ^= shareBytes[j]
806
+ }
807
+ }
808
+
809
+ // Convert reconstructed key to hex
810
+ const reconstructedKeyHex = `0x${bytesToHex(reconstructedKey)}` as HexString
811
+
812
+ // Decrypt blinding factors and brute-force search for values
813
+ const results: Record<string, bigint> = {}
814
+
815
+ for (const [choice, commitmentPoint] of Object.entries(tally.tallies)) {
816
+ // Decrypt the blinding factor for this choice
817
+ const encBlinding = tally.encryptedBlindings[choice]
818
+ if (!encBlinding) {
819
+ throw new CryptoError(
820
+ `missing encrypted blinding factor for choice ${choice}`,
821
+ ErrorCode.DECRYPTION_FAILED,
822
+ { operation: 'revealTally', context: { choice } }
823
+ )
824
+ }
825
+
826
+ // Derive decryption key for this choice's blinding
827
+ const derivedKey = this.deriveEncryptionKey(
828
+ reconstructedKeyHex,
829
+ `${tally.proposalId}-tally-${choice}`
830
+ )
831
+
832
+ let blindingFactor: HexString
833
+ try {
834
+ // Decrypt the blinding factor
835
+ const nonceBytes = hexToBytes(encBlinding.nonce.slice(2))
836
+ const ciphertextBytes = hexToBytes(encBlinding.ciphertext.slice(2))
837
+
838
+ const cipher = xchacha20poly1305(derivedKey, nonceBytes)
839
+ const blindingBytes = cipher.decrypt(ciphertextBytes)
840
+
841
+ blindingFactor = `0x${bytesToHex(blindingBytes)}` as HexString
842
+ } catch (e) {
843
+ throw new CryptoError(
844
+ 'failed to decrypt blinding factor',
845
+ ErrorCode.DECRYPTION_FAILED,
846
+ {
847
+ cause: e instanceof Error ? e : undefined,
848
+ operation: 'revealTally',
849
+ context: { choice },
850
+ }
851
+ )
852
+ } finally {
853
+ secureWipe(derivedKey)
854
+ }
855
+
856
+ // Now brute-force search for the value
857
+ // In production, you'd use ZK range proofs to avoid this
858
+ let found = false
859
+ const maxTries = 1000000n // Reasonable limit for vote counts
860
+
861
+ for (let value = 0n; value <= maxTries; value++) {
862
+ try {
863
+ // Try to create a commitment with this value and the decrypted blinding
864
+ const { commitment: testCommit } = commit(
865
+ value,
866
+ hexToBytes(blindingFactor.slice(2))
867
+ )
868
+
869
+ // Check if it matches
870
+ if (testCommit === commitmentPoint) {
871
+ results[choice] = value
872
+ found = true
873
+ break
874
+ }
875
+ } catch {
876
+ // Invalid commitment, continue
877
+ continue
878
+ }
879
+ }
880
+
881
+ if (!found) {
882
+ throw new CryptoError(
883
+ 'failed to reveal tally - value exceeds searchable range',
884
+ ErrorCode.DECRYPTION_FAILED,
885
+ { operation: 'revealTally', context: { choice, maxTries: maxTries.toString() } }
886
+ )
887
+ }
888
+ }
889
+
890
+ return {
891
+ proposalId: tally.proposalId,
892
+ results,
893
+ voteCount: tally.voteCount,
894
+ timestamp: Date.now(),
895
+ encryptedTally: tally,
896
+ }
897
+ } catch (e) {
898
+ if (e instanceof ValidationError || e instanceof CryptoError) {
899
+ throw e
900
+ }
901
+
902
+ throw new CryptoError(
903
+ 'threshold decryption failed',
904
+ ErrorCode.DECRYPTION_FAILED,
905
+ {
906
+ cause: e instanceof Error ? e : undefined,
907
+ operation: 'revealTally',
908
+ }
909
+ )
910
+ } finally {
911
+ // Securely wipe reconstructed key
912
+ if (reconstructedKey) {
913
+ secureWipe(reconstructedKey)
914
+ }
915
+ }
916
+ }
917
+
918
+ /**
919
+ * Validate encrypted tally structure
920
+ */
921
+ private validateEncryptedTally(tally: EncryptedTally): void {
922
+ if (!tally || typeof tally !== 'object') {
923
+ throw new ValidationError(
924
+ 'tally must be an object',
925
+ 'tally',
926
+ undefined,
927
+ ErrorCode.INVALID_INPUT
928
+ )
929
+ }
930
+
931
+ if (typeof tally.proposalId !== 'string' || tally.proposalId.length === 0) {
932
+ throw new ValidationError(
933
+ 'proposalId must be a non-empty string',
934
+ 'tally.proposalId',
935
+ undefined,
936
+ ErrorCode.INVALID_INPUT
937
+ )
938
+ }
939
+
940
+ if (!tally.tallies || typeof tally.tallies !== 'object') {
941
+ throw new ValidationError(
942
+ 'tallies must be an object',
943
+ 'tally.tallies',
944
+ undefined,
945
+ ErrorCode.INVALID_INPUT
946
+ )
947
+ }
948
+
949
+ // Validate each tally commitment
950
+ for (const [choice, commitment] of Object.entries(tally.tallies)) {
951
+ if (!isValidHex(commitment)) {
952
+ throw new ValidationError(
953
+ `tally for choice ${choice} must be a valid hex string`,
954
+ 'tally.tallies',
955
+ undefined,
956
+ ErrorCode.INVALID_ENCRYPTED_DATA
957
+ )
958
+ }
959
+ }
960
+
961
+ // Validate encrypted blindings
962
+ if (!tally.encryptedBlindings || typeof tally.encryptedBlindings !== 'object') {
963
+ throw new ValidationError(
964
+ 'encryptedBlindings must be an object',
965
+ 'tally.encryptedBlindings',
966
+ undefined,
967
+ ErrorCode.INVALID_INPUT
968
+ )
969
+ }
970
+
971
+ // Validate each encrypted blinding
972
+ for (const [choice, encBlinding] of Object.entries(tally.encryptedBlindings)) {
973
+ if (!encBlinding || typeof encBlinding !== 'object') {
974
+ throw new ValidationError(
975
+ `encrypted blinding for choice ${choice} must be an object`,
976
+ 'tally.encryptedBlindings',
977
+ undefined,
978
+ ErrorCode.INVALID_ENCRYPTED_DATA
979
+ )
980
+ }
981
+
982
+ if (!isValidHex(encBlinding.ciphertext)) {
983
+ throw new ValidationError(
984
+ `encrypted blinding ciphertext for choice ${choice} must be a valid hex string`,
985
+ 'tally.encryptedBlindings.ciphertext',
986
+ undefined,
987
+ ErrorCode.INVALID_ENCRYPTED_DATA
988
+ )
989
+ }
990
+
991
+ if (!isValidHex(encBlinding.nonce)) {
992
+ throw new ValidationError(
993
+ `encrypted blinding nonce for choice ${choice} must be a valid hex string`,
994
+ 'tally.encryptedBlindings.nonce',
995
+ undefined,
996
+ ErrorCode.INVALID_ENCRYPTED_DATA
997
+ )
998
+ }
999
+ }
1000
+
1001
+ if (typeof tally.voteCount !== 'number' || !Number.isInteger(tally.voteCount) || tally.voteCount < 0) {
1002
+ throw new ValidationError(
1003
+ 'voteCount must be a non-negative integer',
1004
+ 'tally.voteCount',
1005
+ { received: tally.voteCount },
1006
+ ErrorCode.INVALID_INPUT
1007
+ )
1008
+ }
1009
+
1010
+ if (typeof tally.timestamp !== 'number' || !Number.isInteger(tally.timestamp)) {
1011
+ throw new ValidationError(
1012
+ 'timestamp must be an integer',
1013
+ 'tally.timestamp',
1014
+ { received: tally.timestamp },
1015
+ ErrorCode.INVALID_INPUT
1016
+ )
1017
+ }
1018
+ }
1019
+
1020
+ /**
1021
+ * Validate encrypted vote structure
1022
+ */
1023
+ private validateEncryptedVote(vote: EncryptedVote): void {
1024
+ if (!vote || typeof vote !== 'object') {
1025
+ throw new ValidationError(
1026
+ 'vote must be an object',
1027
+ 'vote',
1028
+ undefined,
1029
+ ErrorCode.INVALID_INPUT
1030
+ )
1031
+ }
1032
+
1033
+ // Validate ciphertext
1034
+ if (!isValidHex(vote.ciphertext)) {
1035
+ throw new ValidationError(
1036
+ 'ciphertext must be a valid hex string',
1037
+ 'vote.ciphertext',
1038
+ undefined,
1039
+ ErrorCode.INVALID_ENCRYPTED_DATA
1040
+ )
1041
+ }
1042
+
1043
+ // Validate nonce
1044
+ if (!isValidHex(vote.nonce)) {
1045
+ throw new ValidationError(
1046
+ 'nonce must be a valid hex string',
1047
+ 'vote.nonce',
1048
+ undefined,
1049
+ ErrorCode.INVALID_ENCRYPTED_DATA
1050
+ )
1051
+ }
1052
+
1053
+ // Validate encryption key hash
1054
+ if (!isValidHex(vote.encryptionKeyHash)) {
1055
+ throw new ValidationError(
1056
+ 'encryptionKeyHash must be a valid hex string',
1057
+ 'vote.encryptionKeyHash',
1058
+ undefined,
1059
+ ErrorCode.INVALID_ENCRYPTED_DATA
1060
+ )
1061
+ }
1062
+
1063
+ // Validate proposal ID
1064
+ if (typeof vote.proposalId !== 'string' || vote.proposalId.length === 0) {
1065
+ throw new ValidationError(
1066
+ 'proposalId must be a non-empty string',
1067
+ 'vote.proposalId',
1068
+ undefined,
1069
+ ErrorCode.INVALID_INPUT
1070
+ )
1071
+ }
1072
+
1073
+ // Validate voter
1074
+ if (typeof vote.voter !== 'string') {
1075
+ throw new ValidationError(
1076
+ 'voter must be a string',
1077
+ 'vote.voter',
1078
+ { received: typeof vote.voter },
1079
+ ErrorCode.INVALID_INPUT
1080
+ )
1081
+ }
1082
+
1083
+ // Validate timestamp
1084
+ if (typeof vote.timestamp !== 'number' || !Number.isInteger(vote.timestamp)) {
1085
+ throw new ValidationError(
1086
+ 'timestamp must be an integer',
1087
+ 'vote.timestamp',
1088
+ { received: vote.timestamp },
1089
+ ErrorCode.INVALID_INPUT
1090
+ )
1091
+ }
1092
+ }
1093
+ }
1094
+
1095
+ /**
1096
+ * Create a new PrivateVoting instance
1097
+ *
1098
+ * @returns PrivateVoting instance
1099
+ *
1100
+ * @example
1101
+ * ```typescript
1102
+ * import { createPrivateVoting } from '@sip-protocol/sdk'
1103
+ *
1104
+ * const voting = createPrivateVoting()
1105
+ *
1106
+ * const encryptedVote = voting.castVote({
1107
+ * proposalId: 'proposal-123',
1108
+ * choice: 1,
1109
+ * weight: 1000n,
1110
+ * encryptionKey: '0xabc...',
1111
+ * })
1112
+ * ```
1113
+ */
1114
+ export function createPrivateVoting(): PrivateVoting {
1115
+ return new PrivateVoting()
1116
+ }