@sip-protocol/sdk 0.1.0 → 0.1.1

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.
@@ -0,0 +1,911 @@
1
+ /**
2
+ * DAO Treasury Management for SIP Protocol
3
+ *
4
+ * Provides privacy-preserving treasury operations with multi-sig support.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * // Create a treasury
9
+ * const treasury = await Treasury.create({
10
+ * name: 'DAO Treasury',
11
+ * chain: 'ethereum',
12
+ * members: [
13
+ * { address: '0x...', publicKey: '0x...', role: 'owner', name: 'Alice' },
14
+ * { address: '0x...', publicKey: '0x...', role: 'signer', name: 'Bob' },
15
+ * { address: '0x...', publicKey: '0x...', role: 'signer', name: 'Carol' },
16
+ * ],
17
+ * signingThreshold: 2, // 2-of-3 multi-sig
18
+ * })
19
+ *
20
+ * // Create a payment proposal
21
+ * const proposal = await treasury.createPaymentProposal({
22
+ * title: 'Developer Grant',
23
+ * recipient: recipientMetaAddress,
24
+ * token: getStablecoin('USDC', 'ethereum')!,
25
+ * amount: 10000_000000n, // 10,000 USDC
26
+ * purpose: 'salary',
27
+ * })
28
+ *
29
+ * // Sign the proposal
30
+ * await treasury.signProposal(proposal.proposalId, signerPrivateKey)
31
+ *
32
+ * // Execute when approved
33
+ * const payments = await treasury.executeProposal(proposal.proposalId)
34
+ * ```
35
+ */
36
+
37
+ import {
38
+ ProposalStatus,
39
+ PrivacyLevel,
40
+ type TreasuryConfig,
41
+ type TreasuryMember,
42
+ type TreasuryProposal,
43
+ type TreasuryBalance,
44
+ type TreasuryTransaction,
45
+ type CreateTreasuryParams,
46
+ type CreatePaymentProposalParams,
47
+ type CreateBatchProposalParams,
48
+ type BatchPaymentRecipient,
49
+ type ProposalSignature,
50
+ type AuditorViewingKey,
51
+ type Asset,
52
+ type ChainId,
53
+ type HexString,
54
+ type ViewingKey,
55
+ type ShieldedPayment,
56
+ } from '@sip-protocol/types'
57
+ import { secp256k1 } from '@noble/curves/secp256k1'
58
+ import { sha256 } from '@noble/hashes/sha256'
59
+ import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils'
60
+
61
+ import { ValidationError, ErrorCode } from '../errors'
62
+ import { isValidChainId } from '../validation'
63
+ import { generateViewingKey, deriveViewingKey } from '../privacy'
64
+ import { createShieldedPayment, PaymentBuilder } from '../payment'
65
+ import { secureWipe } from '../secure-memory'
66
+
67
+ /**
68
+ * Default proposal TTL (7 days)
69
+ */
70
+ const DEFAULT_PROPOSAL_TTL = 7 * 24 * 60 * 60
71
+
72
+ /**
73
+ * Treasury class - manages DAO treasury with multi-sig support
74
+ */
75
+ export class Treasury {
76
+ private config: TreasuryConfig
77
+ private proposals: Map<string, TreasuryProposal> = new Map()
78
+ private balances: Map<string, TreasuryBalance> = new Map()
79
+ private transactions: TreasuryTransaction[] = []
80
+ private auditorKeys: Map<string, AuditorViewingKey> = new Map()
81
+
82
+ private constructor(config: TreasuryConfig) {
83
+ this.config = config
84
+ }
85
+
86
+ /**
87
+ * Create a new treasury
88
+ */
89
+ static async create(params: CreateTreasuryParams): Promise<Treasury> {
90
+ // Validate params
91
+ validateCreateTreasuryParams(params)
92
+
93
+ const now = Math.floor(Date.now() / 1000)
94
+
95
+ // Generate treasury ID
96
+ const treasuryId = generateTreasuryId()
97
+
98
+ // Generate master viewing key
99
+ const masterViewingKey = generateViewingKey(`treasury/${treasuryId}`)
100
+
101
+ // Build config
102
+ const config: TreasuryConfig = {
103
+ treasuryId,
104
+ name: params.name,
105
+ description: params.description,
106
+ chain: params.chain,
107
+ signingThreshold: params.signingThreshold,
108
+ totalSigners: params.members.filter(m => m.role === 'signer' || m.role === 'owner' || m.role === 'admin').length,
109
+ members: params.members.map(m => ({
110
+ ...m,
111
+ addedAt: now,
112
+ })),
113
+ defaultPrivacy: params.defaultPrivacy ?? PrivacyLevel.SHIELDED,
114
+ masterViewingKey,
115
+ dailyLimit: params.dailyLimit,
116
+ transactionLimit: params.transactionLimit,
117
+ createdAt: now,
118
+ updatedAt: now,
119
+ }
120
+
121
+ return new Treasury(config)
122
+ }
123
+
124
+ /**
125
+ * Load a treasury from config
126
+ */
127
+ static fromConfig(config: TreasuryConfig): Treasury {
128
+ return new Treasury(config)
129
+ }
130
+
131
+ // ─── Getters ─────────────────────────────────────────────────────────────────
132
+
133
+ get treasuryId(): string {
134
+ return this.config.treasuryId
135
+ }
136
+
137
+ get name(): string {
138
+ return this.config.name
139
+ }
140
+
141
+ get chain(): ChainId {
142
+ return this.config.chain
143
+ }
144
+
145
+ get signingThreshold(): number {
146
+ return this.config.signingThreshold
147
+ }
148
+
149
+ get members(): TreasuryMember[] {
150
+ return [...this.config.members]
151
+ }
152
+
153
+ get masterViewingKey(): ViewingKey | undefined {
154
+ return this.config.masterViewingKey
155
+ }
156
+
157
+ /**
158
+ * Get treasury configuration
159
+ */
160
+ getConfig(): TreasuryConfig {
161
+ return { ...this.config }
162
+ }
163
+
164
+ // ─── Member Management ───────────────────────────────────────────────────────
165
+
166
+ /**
167
+ * Get a member by address
168
+ */
169
+ getMember(address: string): TreasuryMember | undefined {
170
+ return this.config.members.find(m => m.address.toLowerCase() === address.toLowerCase())
171
+ }
172
+
173
+ /**
174
+ * Check if an address is a signer
175
+ */
176
+ isSigner(address: string): boolean {
177
+ const member = this.getMember(address)
178
+ return member !== undefined && ['owner', 'admin', 'signer'].includes(member.role)
179
+ }
180
+
181
+ /**
182
+ * Check if an address can create proposals
183
+ */
184
+ canCreateProposal(address: string): boolean {
185
+ const member = this.getMember(address)
186
+ return member !== undefined && ['owner', 'admin', 'signer'].includes(member.role)
187
+ }
188
+
189
+ /**
190
+ * Get all signers
191
+ */
192
+ getSigners(): TreasuryMember[] {
193
+ return this.config.members.filter(m => ['owner', 'admin', 'signer'].includes(m.role))
194
+ }
195
+
196
+ // ─── Proposal Management ─────────────────────────────────────────────────────
197
+
198
+ /**
199
+ * Create a single payment proposal
200
+ */
201
+ async createPaymentProposal(params: CreatePaymentProposalParams): Promise<TreasuryProposal> {
202
+ validatePaymentProposalParams(params, this.config)
203
+
204
+ const now = Math.floor(Date.now() / 1000)
205
+ const proposalId = generateProposalId()
206
+
207
+ const proposal: TreasuryProposal = {
208
+ proposalId,
209
+ treasuryId: this.config.treasuryId,
210
+ type: 'payment',
211
+ status: ProposalStatus.PENDING,
212
+ proposer: '', // Should be set by caller
213
+ title: params.title,
214
+ description: params.description,
215
+ createdAt: now,
216
+ expiresAt: now + (params.ttl ?? DEFAULT_PROPOSAL_TTL),
217
+ requiredSignatures: this.config.signingThreshold,
218
+ signatures: [],
219
+ payment: {
220
+ recipient: params.recipient,
221
+ token: params.token,
222
+ amount: params.amount,
223
+ memo: params.memo,
224
+ purpose: params.purpose,
225
+ privacy: params.privacy ?? this.config.defaultPrivacy,
226
+ },
227
+ }
228
+
229
+ this.proposals.set(proposalId, proposal)
230
+ return proposal
231
+ }
232
+
233
+ /**
234
+ * Create a batch payment proposal
235
+ */
236
+ async createBatchProposal(params: CreateBatchProposalParams): Promise<TreasuryProposal> {
237
+ validateBatchProposalParams(params, this.config)
238
+
239
+ const now = Math.floor(Date.now() / 1000)
240
+ const proposalId = generateProposalId()
241
+
242
+ // Calculate total amount
243
+ const totalAmount = params.recipients.reduce((sum, r) => sum + r.amount, 0n)
244
+
245
+ const proposal: TreasuryProposal = {
246
+ proposalId,
247
+ treasuryId: this.config.treasuryId,
248
+ type: 'batch_payment',
249
+ status: ProposalStatus.PENDING,
250
+ proposer: '',
251
+ title: params.title,
252
+ description: params.description,
253
+ createdAt: now,
254
+ expiresAt: now + (params.ttl ?? DEFAULT_PROPOSAL_TTL),
255
+ requiredSignatures: this.config.signingThreshold,
256
+ signatures: [],
257
+ batchPayment: {
258
+ token: params.token,
259
+ recipients: params.recipients,
260
+ totalAmount,
261
+ privacy: params.privacy ?? this.config.defaultPrivacy,
262
+ },
263
+ }
264
+
265
+ this.proposals.set(proposalId, proposal)
266
+ return proposal
267
+ }
268
+
269
+ /**
270
+ * Get a proposal by ID
271
+ */
272
+ getProposal(proposalId: string): TreasuryProposal | undefined {
273
+ return this.proposals.get(proposalId)
274
+ }
275
+
276
+ /**
277
+ * Get all proposals
278
+ */
279
+ getAllProposals(): TreasuryProposal[] {
280
+ return Array.from(this.proposals.values())
281
+ }
282
+
283
+ /**
284
+ * Get pending proposals
285
+ */
286
+ getPendingProposals(): TreasuryProposal[] {
287
+ return this.getAllProposals().filter(p => p.status === ProposalStatus.PENDING)
288
+ }
289
+
290
+ /**
291
+ * Sign a proposal
292
+ */
293
+ async signProposal(
294
+ proposalId: string,
295
+ signerAddress: string,
296
+ privateKey: HexString,
297
+ approve: boolean = true,
298
+ ): Promise<TreasuryProposal> {
299
+ const proposal = this.proposals.get(proposalId)
300
+ if (!proposal) {
301
+ throw new ValidationError(
302
+ `proposal not found: ${proposalId}`,
303
+ 'proposalId',
304
+ undefined,
305
+ ErrorCode.INVALID_INPUT
306
+ )
307
+ }
308
+
309
+ // Validate signer
310
+ if (!this.isSigner(signerAddress)) {
311
+ throw new ValidationError(
312
+ `address is not a signer: ${signerAddress}`,
313
+ 'signerAddress',
314
+ undefined,
315
+ ErrorCode.INVALID_INPUT
316
+ )
317
+ }
318
+
319
+ // Check if already signed
320
+ if (proposal.signatures.some(s => s.signer.toLowerCase() === signerAddress.toLowerCase())) {
321
+ throw new ValidationError(
322
+ `signer has already signed this proposal`,
323
+ 'signerAddress',
324
+ undefined,
325
+ ErrorCode.INVALID_INPUT
326
+ )
327
+ }
328
+
329
+ // Check proposal status
330
+ if (proposal.status !== ProposalStatus.PENDING) {
331
+ throw new ValidationError(
332
+ `proposal is not pending: ${proposal.status}`,
333
+ 'proposalId',
334
+ undefined,
335
+ ErrorCode.INVALID_INPUT
336
+ )
337
+ }
338
+
339
+ // Check expiration
340
+ const now = Math.floor(Date.now() / 1000)
341
+ if (now > proposal.expiresAt) {
342
+ proposal.status = ProposalStatus.EXPIRED
343
+ throw new ValidationError(
344
+ 'proposal has expired',
345
+ 'proposalId',
346
+ undefined,
347
+ ErrorCode.INVALID_INPUT
348
+ )
349
+ }
350
+
351
+ // Create and verify signature
352
+ const messageHash = computeProposalHash(proposal)
353
+ const signature = signMessage(messageHash, privateKey)
354
+
355
+ // Verify signature before accepting
356
+ // Get signer's public key from member record
357
+ const signerMember = this.getMember(signerAddress)
358
+ if (signerMember?.publicKey) {
359
+ const isValid = verifySignature(messageHash, signature, signerMember.publicKey)
360
+ if (!isValid) {
361
+ throw new ValidationError(
362
+ 'signature verification failed',
363
+ 'signature',
364
+ undefined,
365
+ ErrorCode.INVALID_INPUT
366
+ )
367
+ }
368
+ }
369
+
370
+ const proposalSignature: ProposalSignature = {
371
+ signer: signerAddress,
372
+ signature,
373
+ signedAt: now,
374
+ approved: approve,
375
+ }
376
+
377
+ proposal.signatures.push(proposalSignature)
378
+
379
+ // Check if we have enough approvals
380
+ const approvals = proposal.signatures.filter(s => s.approved).length
381
+ const rejections = proposal.signatures.filter(s => !s.approved).length
382
+
383
+ if (approvals >= proposal.requiredSignatures) {
384
+ proposal.status = ProposalStatus.APPROVED
385
+ } else if (rejections > this.config.totalSigners - proposal.requiredSignatures) {
386
+ proposal.status = ProposalStatus.REJECTED
387
+ }
388
+
389
+ return proposal
390
+ }
391
+
392
+ /**
393
+ * Execute an approved proposal
394
+ */
395
+ async executeProposal(proposalId: string): Promise<ShieldedPayment[]> {
396
+ const proposal = this.proposals.get(proposalId)
397
+ if (!proposal) {
398
+ throw new ValidationError(
399
+ `proposal not found: ${proposalId}`,
400
+ 'proposalId',
401
+ undefined,
402
+ ErrorCode.INVALID_INPUT
403
+ )
404
+ }
405
+
406
+ if (proposal.status !== ProposalStatus.APPROVED) {
407
+ throw new ValidationError(
408
+ `proposal is not approved: ${proposal.status}`,
409
+ 'proposalId',
410
+ undefined,
411
+ ErrorCode.INVALID_INPUT
412
+ )
413
+ }
414
+
415
+ const payments: ShieldedPayment[] = []
416
+
417
+ if (proposal.type === 'payment' && proposal.payment) {
418
+ // Execute single payment
419
+ const payment = await createShieldedPayment({
420
+ token: proposal.payment.token,
421
+ amount: proposal.payment.amount,
422
+ recipientMetaAddress: proposal.payment.privacy !== PrivacyLevel.TRANSPARENT
423
+ ? proposal.payment.recipient
424
+ : undefined,
425
+ recipientAddress: proposal.payment.privacy === PrivacyLevel.TRANSPARENT
426
+ ? proposal.payment.recipient
427
+ : undefined,
428
+ privacy: proposal.payment.privacy,
429
+ viewingKey: this.config.masterViewingKey?.key,
430
+ sourceChain: this.config.chain,
431
+ purpose: proposal.payment.purpose,
432
+ memo: proposal.payment.memo,
433
+ })
434
+ payments.push(payment)
435
+ } else if (proposal.type === 'batch_payment' && proposal.batchPayment) {
436
+ // Execute batch payments
437
+ for (const recipient of proposal.batchPayment.recipients) {
438
+ const payment = await createShieldedPayment({
439
+ token: proposal.batchPayment.token,
440
+ amount: recipient.amount,
441
+ recipientMetaAddress: proposal.batchPayment.privacy !== PrivacyLevel.TRANSPARENT
442
+ ? recipient.address
443
+ : undefined,
444
+ recipientAddress: proposal.batchPayment.privacy === PrivacyLevel.TRANSPARENT
445
+ ? recipient.address
446
+ : undefined,
447
+ privacy: proposal.batchPayment.privacy,
448
+ viewingKey: this.config.masterViewingKey?.key,
449
+ sourceChain: this.config.chain,
450
+ purpose: recipient.purpose,
451
+ memo: recipient.memo,
452
+ })
453
+ payments.push(payment)
454
+ }
455
+ }
456
+
457
+ // Update proposal
458
+ proposal.status = ProposalStatus.EXECUTED
459
+ proposal.executedAt = Math.floor(Date.now() / 1000)
460
+ proposal.resultPayments = payments
461
+
462
+ return payments
463
+ }
464
+
465
+ /**
466
+ * Cancel a proposal (only by proposer or owner)
467
+ */
468
+ cancelProposal(proposalId: string, cancellerAddress: string): TreasuryProposal {
469
+ const proposal = this.proposals.get(proposalId)
470
+ if (!proposal) {
471
+ throw new ValidationError(
472
+ `proposal not found: ${proposalId}`,
473
+ 'proposalId',
474
+ undefined,
475
+ ErrorCode.INVALID_INPUT
476
+ )
477
+ }
478
+
479
+ // Only proposer or owner can cancel
480
+ const member = this.getMember(cancellerAddress)
481
+ const isProposer = proposal.proposer.toLowerCase() === cancellerAddress.toLowerCase()
482
+ const isOwner = member?.role === 'owner'
483
+
484
+ if (!isProposer && !isOwner) {
485
+ throw new ValidationError(
486
+ 'only proposer or owner can cancel proposals',
487
+ 'cancellerAddress',
488
+ undefined,
489
+ ErrorCode.INVALID_INPUT
490
+ )
491
+ }
492
+
493
+ if (proposal.status !== ProposalStatus.PENDING) {
494
+ throw new ValidationError(
495
+ `proposal is not pending: ${proposal.status}`,
496
+ 'proposalId',
497
+ undefined,
498
+ ErrorCode.INVALID_INPUT
499
+ )
500
+ }
501
+
502
+ proposal.status = ProposalStatus.CANCELLED
503
+ return proposal
504
+ }
505
+
506
+ // ─── Auditor Access ──────────────────────────────────────────────────────────
507
+
508
+ /**
509
+ * Grant viewing access to an auditor
510
+ */
511
+ grantAuditorAccess(
512
+ auditorId: string,
513
+ auditorName: string,
514
+ granterAddress: string,
515
+ scope: 'all' | 'inbound' | 'outbound' = 'all',
516
+ validUntil?: number,
517
+ ): AuditorViewingKey {
518
+ // Only owners can grant auditor access
519
+ const member = this.getMember(granterAddress)
520
+ if (!member || member.role !== 'owner') {
521
+ throw new ValidationError(
522
+ 'only owners can grant auditor access',
523
+ 'granterAddress',
524
+ undefined,
525
+ ErrorCode.INVALID_INPUT
526
+ )
527
+ }
528
+
529
+ if (!this.config.masterViewingKey) {
530
+ throw new ValidationError(
531
+ 'treasury has no master viewing key',
532
+ 'masterViewingKey',
533
+ undefined,
534
+ ErrorCode.INVALID_INPUT
535
+ )
536
+ }
537
+
538
+ // Derive auditor-specific viewing key
539
+ const viewingKey = deriveViewingKey(
540
+ this.config.masterViewingKey,
541
+ `auditor/${auditorId}`
542
+ )
543
+
544
+ const auditorKey: AuditorViewingKey = {
545
+ auditorId,
546
+ name: auditorName,
547
+ viewingKey,
548
+ scope,
549
+ validFrom: Math.floor(Date.now() / 1000),
550
+ validUntil,
551
+ grantedBy: granterAddress,
552
+ grantedAt: Math.floor(Date.now() / 1000),
553
+ }
554
+
555
+ this.auditorKeys.set(auditorId, auditorKey)
556
+ return auditorKey
557
+ }
558
+
559
+ /**
560
+ * Revoke auditor access
561
+ */
562
+ revokeAuditorAccess(auditorId: string, revokerAddress: string): boolean {
563
+ const member = this.getMember(revokerAddress)
564
+ if (!member || member.role !== 'owner') {
565
+ throw new ValidationError(
566
+ 'only owners can revoke auditor access',
567
+ 'revokerAddress',
568
+ undefined,
569
+ ErrorCode.INVALID_INPUT
570
+ )
571
+ }
572
+
573
+ return this.auditorKeys.delete(auditorId)
574
+ }
575
+
576
+ /**
577
+ * Get all auditor keys
578
+ */
579
+ getAuditorKeys(): AuditorViewingKey[] {
580
+ return Array.from(this.auditorKeys.values())
581
+ }
582
+
583
+ // ─── Balance Management ──────────────────────────────────────────────────────
584
+
585
+ /**
586
+ * Update balance for a token (called after deposits/withdrawals)
587
+ */
588
+ updateBalance(token: Asset, balance: bigint): void {
589
+ const key = `${token.chain}:${token.symbol}`
590
+ const committed = this.getCommittedAmount(token)
591
+
592
+ this.balances.set(key, {
593
+ token,
594
+ balance,
595
+ committed,
596
+ available: balance - committed,
597
+ updatedAt: Math.floor(Date.now() / 1000),
598
+ })
599
+ }
600
+
601
+ /**
602
+ * Get balance for a token
603
+ */
604
+ getBalance(token: Asset): TreasuryBalance | undefined {
605
+ const key = `${token.chain}:${token.symbol}`
606
+ return this.balances.get(key)
607
+ }
608
+
609
+ /**
610
+ * Get all balances
611
+ */
612
+ getAllBalances(): TreasuryBalance[] {
613
+ return Array.from(this.balances.values())
614
+ }
615
+
616
+ /**
617
+ * Get committed amount for pending proposals
618
+ */
619
+ private getCommittedAmount(token: Asset): bigint {
620
+ let committed = 0n
621
+
622
+ for (const proposal of this.proposals.values()) {
623
+ if (proposal.status !== ProposalStatus.PENDING) continue
624
+
625
+ if (proposal.type === 'payment' && proposal.payment) {
626
+ if (proposal.payment.token.symbol === token.symbol &&
627
+ proposal.payment.token.chain === token.chain) {
628
+ committed += proposal.payment.amount
629
+ }
630
+ } else if (proposal.type === 'batch_payment' && proposal.batchPayment) {
631
+ if (proposal.batchPayment.token.symbol === token.symbol &&
632
+ proposal.batchPayment.token.chain === token.chain) {
633
+ committed += proposal.batchPayment.totalAmount
634
+ }
635
+ }
636
+ }
637
+
638
+ return committed
639
+ }
640
+
641
+ // ─── Serialization ───────────────────────────────────────────────────────────
642
+
643
+ /**
644
+ * Serialize treasury to JSON
645
+ */
646
+ toJSON(): string {
647
+ return JSON.stringify({
648
+ config: this.config,
649
+ proposals: Array.from(this.proposals.entries()),
650
+ balances: Array.from(this.balances.entries()),
651
+ auditorKeys: Array.from(this.auditorKeys.entries()),
652
+ }, (_, value) => typeof value === 'bigint' ? value.toString() : value)
653
+ }
654
+
655
+ /**
656
+ * Deserialize treasury from JSON
657
+ */
658
+ static fromJSON(json: string): Treasury {
659
+ const data = JSON.parse(json, (key, value) => {
660
+ // Convert string numbers back to bigint for known fields
661
+ if (typeof value === 'string' && /^\d+$/.test(value) &&
662
+ ['amount', 'totalAmount', 'balance', 'committed', 'available',
663
+ 'dailyLimit', 'transactionLimit'].includes(key)) {
664
+ return BigInt(value)
665
+ }
666
+ return value
667
+ })
668
+
669
+ const treasury = new Treasury(data.config)
670
+ treasury.proposals = new Map(data.proposals)
671
+ treasury.balances = new Map(data.balances)
672
+ treasury.auditorKeys = new Map(data.auditorKeys)
673
+
674
+ return treasury
675
+ }
676
+ }
677
+
678
+ // ─── Helper Functions ────────────────────────────────────────────────────────
679
+
680
+ function generateTreasuryId(): string {
681
+ const bytes = randomBytes(16)
682
+ return `treasury_${bytesToHex(bytes)}`
683
+ }
684
+
685
+ function generateProposalId(): string {
686
+ const bytes = randomBytes(16)
687
+ return `prop_${bytesToHex(bytes)}`
688
+ }
689
+
690
+ function computeProposalHash(proposal: TreasuryProposal): Uint8Array {
691
+ const data = JSON.stringify({
692
+ proposalId: proposal.proposalId,
693
+ treasuryId: proposal.treasuryId,
694
+ type: proposal.type,
695
+ payment: proposal.payment,
696
+ batchPayment: proposal.batchPayment,
697
+ createdAt: proposal.createdAt,
698
+ expiresAt: proposal.expiresAt,
699
+ }, (_, value) => typeof value === 'bigint' ? value.toString() : value)
700
+
701
+ return sha256(new TextEncoder().encode(data))
702
+ }
703
+
704
+ function signMessage(messageHash: Uint8Array, privateKey: HexString): HexString {
705
+ const keyHex = privateKey.startsWith('0x') ? privateKey.slice(2) : privateKey
706
+ const keyBytes = hexToBytes(keyHex)
707
+
708
+ try {
709
+ const signature = secp256k1.sign(messageHash, keyBytes)
710
+ return `0x${signature.toCompactHex()}` as HexString
711
+ } finally {
712
+ secureWipe(keyBytes)
713
+ }
714
+ }
715
+
716
+ function verifySignature(
717
+ messageHash: Uint8Array,
718
+ signature: HexString,
719
+ publicKey: HexString,
720
+ ): boolean {
721
+ const sigHex = signature.startsWith('0x') ? signature.slice(2) : signature
722
+ const pubKeyHex = publicKey.startsWith('0x') ? publicKey.slice(2) : publicKey
723
+
724
+ try {
725
+ // Convert hex signature to bytes and verify
726
+ const sigBytes = hexToBytes(sigHex)
727
+ const pubKeyBytes = hexToBytes(pubKeyHex)
728
+
729
+ return secp256k1.verify(sigBytes, messageHash, pubKeyBytes)
730
+ } catch {
731
+ return false
732
+ }
733
+ }
734
+
735
+ function validateCreateTreasuryParams(params: CreateTreasuryParams): void {
736
+ if (!params.name || params.name.trim().length === 0) {
737
+ throw new ValidationError(
738
+ 'treasury name is required',
739
+ 'name',
740
+ undefined,
741
+ ErrorCode.MISSING_REQUIRED
742
+ )
743
+ }
744
+
745
+ if (!isValidChainId(params.chain)) {
746
+ throw new ValidationError(
747
+ `invalid chain: ${params.chain}`,
748
+ 'chain',
749
+ undefined,
750
+ ErrorCode.INVALID_INPUT
751
+ )
752
+ }
753
+
754
+ if (!params.members || params.members.length === 0) {
755
+ throw new ValidationError(
756
+ 'at least one member is required',
757
+ 'members',
758
+ undefined,
759
+ ErrorCode.MISSING_REQUIRED
760
+ )
761
+ }
762
+
763
+ // Check for at least one owner
764
+ const hasOwner = params.members.some(m => m.role === 'owner')
765
+ if (!hasOwner) {
766
+ throw new ValidationError(
767
+ 'at least one owner is required',
768
+ 'members',
769
+ undefined,
770
+ ErrorCode.INVALID_INPUT
771
+ )
772
+ }
773
+
774
+ // Count signers
775
+ const signerCount = params.members.filter(m =>
776
+ ['owner', 'admin', 'signer'].includes(m.role)
777
+ ).length
778
+
779
+ if (params.signingThreshold < 1) {
780
+ throw new ValidationError(
781
+ 'signing threshold must be at least 1',
782
+ 'signingThreshold',
783
+ undefined,
784
+ ErrorCode.INVALID_INPUT
785
+ )
786
+ }
787
+
788
+ if (params.signingThreshold > signerCount) {
789
+ throw new ValidationError(
790
+ `signing threshold (${params.signingThreshold}) cannot exceed number of signers (${signerCount})`,
791
+ 'signingThreshold',
792
+ undefined,
793
+ ErrorCode.INVALID_INPUT
794
+ )
795
+ }
796
+ }
797
+
798
+ function validatePaymentProposalParams(
799
+ params: CreatePaymentProposalParams,
800
+ config: TreasuryConfig,
801
+ ): void {
802
+ if (!params.title || params.title.trim().length === 0) {
803
+ throw new ValidationError(
804
+ 'proposal title is required',
805
+ 'title',
806
+ undefined,
807
+ ErrorCode.MISSING_REQUIRED
808
+ )
809
+ }
810
+
811
+ if (!params.recipient || params.recipient.trim().length === 0) {
812
+ throw new ValidationError(
813
+ 'recipient is required',
814
+ 'recipient',
815
+ undefined,
816
+ ErrorCode.MISSING_REQUIRED
817
+ )
818
+ }
819
+
820
+ if (!params.token) {
821
+ throw new ValidationError(
822
+ 'token is required',
823
+ 'token',
824
+ undefined,
825
+ ErrorCode.MISSING_REQUIRED
826
+ )
827
+ }
828
+
829
+ if (params.amount <= 0n) {
830
+ throw new ValidationError(
831
+ 'amount must be positive',
832
+ 'amount',
833
+ undefined,
834
+ ErrorCode.INVALID_INPUT
835
+ )
836
+ }
837
+
838
+ // Check transaction limit
839
+ if (config.transactionLimit && params.amount > config.transactionLimit) {
840
+ throw new ValidationError(
841
+ `amount exceeds transaction limit (${config.transactionLimit})`,
842
+ 'amount',
843
+ undefined,
844
+ ErrorCode.INVALID_INPUT
845
+ )
846
+ }
847
+ }
848
+
849
+ function validateBatchProposalParams(
850
+ params: CreateBatchProposalParams,
851
+ config: TreasuryConfig,
852
+ ): void {
853
+ if (!params.title || params.title.trim().length === 0) {
854
+ throw new ValidationError(
855
+ 'proposal title is required',
856
+ 'title',
857
+ undefined,
858
+ ErrorCode.MISSING_REQUIRED
859
+ )
860
+ }
861
+
862
+ if (!params.recipients || params.recipients.length === 0) {
863
+ throw new ValidationError(
864
+ 'at least one recipient is required',
865
+ 'recipients',
866
+ undefined,
867
+ ErrorCode.MISSING_REQUIRED
868
+ )
869
+ }
870
+
871
+ if (!params.token) {
872
+ throw new ValidationError(
873
+ 'token is required',
874
+ 'token',
875
+ undefined,
876
+ ErrorCode.MISSING_REQUIRED
877
+ )
878
+ }
879
+
880
+ // Validate each recipient
881
+ for (let i = 0; i < params.recipients.length; i++) {
882
+ const r = params.recipients[i]
883
+ if (!r.address || r.address.trim().length === 0) {
884
+ throw new ValidationError(
885
+ `recipient ${i} address is required`,
886
+ `recipients[${i}].address`,
887
+ undefined,
888
+ ErrorCode.MISSING_REQUIRED
889
+ )
890
+ }
891
+ if (r.amount <= 0n) {
892
+ throw new ValidationError(
893
+ `recipient ${i} amount must be positive`,
894
+ `recipients[${i}].amount`,
895
+ undefined,
896
+ ErrorCode.INVALID_INPUT
897
+ )
898
+ }
899
+ }
900
+
901
+ // Check total against transaction limit
902
+ const total = params.recipients.reduce((sum, r) => sum + r.amount, 0n)
903
+ if (config.transactionLimit && total > config.transactionLimit) {
904
+ throw new ValidationError(
905
+ `total amount (${total}) exceeds transaction limit (${config.transactionLimit})`,
906
+ 'recipients',
907
+ undefined,
908
+ ErrorCode.INVALID_INPUT
909
+ )
910
+ }
911
+ }