@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.
- package/dist/index.d.mts +3236 -1554
- package/dist/index.d.ts +3236 -1554
- package/dist/index.js +9184 -3520
- package/dist/index.mjs +8998 -3379
- package/package.json +5 -2
- package/src/adapters/near-intents.ts +48 -35
- package/src/adapters/oneclick-client.ts +6 -0
- package/src/compliance/compliance-manager.ts +1035 -0
- package/src/compliance/index.ts +43 -0
- package/src/index.ts +129 -2
- package/src/payment/index.ts +54 -0
- package/src/payment/payment.ts +623 -0
- package/src/payment/stablecoins.ts +306 -0
- package/src/privacy.ts +127 -94
- package/src/proofs/circuits/fulfillment_proof.json +1 -0
- package/src/proofs/circuits/funding_proof.json +1 -0
- package/src/proofs/circuits/validity_proof.json +1 -0
- package/src/proofs/interface.ts +13 -1
- package/src/proofs/noir.ts +967 -97
- package/src/secure-memory.ts +147 -0
- package/src/sip.ts +399 -37
- package/src/stealth.ts +116 -84
- package/src/treasury/index.ts +43 -0
- package/src/treasury/treasury.ts +911 -0
- package/src/wallet/hardware/index.ts +87 -0
- package/src/wallet/hardware/ledger.ts +628 -0
- package/src/wallet/hardware/mock.ts +667 -0
- package/src/wallet/hardware/trezor.ts +657 -0
- package/src/wallet/hardware/types.ts +317 -0
- package/src/wallet/index.ts +40 -0
- package/src/zcash/shielded-service.ts +59 -1
|
@@ -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
|
+
}
|