@openzeppelin/compact-contracts 0.4.0-alpha.5 → 0.4.0
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/README.md +1 -1
- package/access/AccessControl.compact +1 -1
- package/access/Ownable.compact +1 -3
- package/access/ShieldedAccessControl.compact +44 -41
- package/access/ZOwnablePK.compact +12 -5
- package/crypto/EcdhMask.compact +11 -1
- package/crypto/Ecdsa.compact +1 -1
- package/crypto/Eip712.compact +1 -1
- package/crypto/ElGamal.compact +1 -1
- package/multisig/ForwarderPrivate.compact +17 -6
- package/multisig/ForwarderShielded.compact +1 -1
- package/multisig/ForwarderUnshielded.compact +1 -1
- package/multisig/ProposalManager.compact +65 -127
- package/multisig/RecipientTypes.compact +48 -0
- package/multisig/Secp256k1EcdsaMultisig.compact +275 -0
- package/multisig/ShieldedTreasury.compact +20 -8
- package/multisig/ShieldedTreasuryStateless.compact +11 -5
- package/multisig/Signer.compact +1 -1
- package/multisig/UnshieldedTreasury.compact +24 -11
- package/multisig/examples/ForwarderPrivateExample.compact +3 -2
- package/multisig/examples/ForwarderShieldedExample.compact +1 -1
- package/multisig/examples/ForwarderUnshieldedExample.compact +1 -1
- package/multisig/examples/NativeShieldedTokenIssuerExample.compact +4 -4
- package/multisig/examples/ShieldedMultiSigV2Example.compact +7 -9
- package/multisig/presets/NativeShieldedTokenIssuer.compact +88 -84
- package/multisig/presets/ShieldedMultiSigV2.compact +50 -52
- package/package.json +1 -1
- package/security/Allowlist.compact +1 -1
- package/security/Blocklist.compact +1 -1
- package/security/Initializable.compact +1 -3
- package/security/Pausable.compact +1 -1
- package/token/ConfidentialFungibleToken.compact +70 -22
- package/token/FungibleToken.compact +1 -3
- package/token/MultiToken.compact +1 -3
- package/token/NativeShieldedToken.compact +11 -3
- package/token/NativeShieldedTokenCore.compact +1 -1
- package/token/NativeShieldedTokenFamily.compact +1 -1
- package/token/NonFungibleToken.compact +1 -3
- package/token/examples/ZOwnableNativeShieldedTokenExample.compact +109 -0
- package/token/extensions/ConfidentialFungibleTokenPublicSupply.compact +1 -1
- package/token/extensions/NativeShieldedTokenDerivedNonce.compact +1 -1
- package/token/presets/ZOwnableNativeShieldedToken.compact +314 -0
- package/utils/EvmAbi.compact +1 -1
- package/utils/Utils.compact +1 -1
- package/multisig/EcdsaSignerManager.compact +0 -248
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
// OpenZeppelin Compact Contracts v0.4.0
|
|
2
|
+
// OpenZeppelin Compact Contracts v0.4.0 (multisig/ProposalManager.compact)
|
|
3
3
|
|
|
4
4
|
pragma language_version >= 0.26.0;
|
|
5
5
|
|
|
@@ -8,14 +8,15 @@ pragma language_version >= 0.26.0;
|
|
|
8
8
|
* @description Token-agnostic proposal lifecycle management for multisig
|
|
9
9
|
* governance contracts.
|
|
10
10
|
*
|
|
11
|
-
* Supports shielded and unshielded proposals through
|
|
12
|
-
*
|
|
13
|
-
* provide safe construction of recipients
|
|
14
|
-
* internal Bytes<32> representation to consumers.
|
|
11
|
+
* Supports shielded and unshielded proposals through the `Recipient` type
|
|
12
|
+
* and its `RecipientKind` tag, declared in `RecipientTypes` and re-exported
|
|
13
|
+
* here. Typed helper circuits provide safe construction of recipients
|
|
14
|
+
* without exposing the internal Bytes<32> representation to consumers.
|
|
15
15
|
*
|
|
16
16
|
* @warning PROPOSALS ARE PUBLIC. `_proposals` is exported ledger state, so
|
|
17
|
-
* every proposal's recipient address, token color, amount, and
|
|
18
|
-
* readable off-chain, and `_nextProposalId` reveals how many have been
|
|
17
|
+
* every proposal's recipient address, token color, amount, expiry, and status
|
|
18
|
+
* is readable off-chain, and `_nextProposalId` reveals how many have been
|
|
19
|
+
* created.
|
|
19
20
|
* This holds regardless of which treasury a consumer composes: pairing this
|
|
20
21
|
* module with `ShieldedTreasuryStateless` still publishes the full intent of
|
|
21
22
|
* every transfer, even though the transfer itself is shielded. The transfer is
|
|
@@ -30,18 +31,23 @@ pragma language_version >= 0.26.0;
|
|
|
30
31
|
*/
|
|
31
32
|
module ProposalManager {
|
|
32
33
|
import CompactStandardLibrary;
|
|
34
|
+
import "./RecipientTypes";
|
|
35
|
+
|
|
36
|
+
export { Recipient, RecipientKind };
|
|
33
37
|
|
|
34
38
|
// ─── Types ──────────────────────────────────────────────────────
|
|
35
39
|
|
|
36
40
|
/**
|
|
37
|
-
* @description Proposal lifecycle
|
|
38
|
-
* `
|
|
39
|
-
*
|
|
40
|
-
* - `Inactive`: no proposal with that id exists.
|
|
41
|
-
* - `Active`:
|
|
42
|
-
*
|
|
43
|
-
* - `
|
|
44
|
-
* - `
|
|
41
|
+
* @description Proposal lifecycle status. `Proposal.status` stores `Active`,
|
|
42
|
+
* `Executed`, or `Cancelled`; `getProposalStatus` derives the other two.
|
|
43
|
+
*
|
|
44
|
+
* - `Inactive`: no proposal with that id exists. Never stored.
|
|
45
|
+
* - `Active`: created and not retired. Stored. Executable only while the
|
|
46
|
+
* block time is below `Proposal.expiry`.
|
|
47
|
+
* - `Executed`: `_markExecuted` ran. Stored, terminal.
|
|
48
|
+
* - `Cancelled`: `_cancelProposal` ran. Stored, terminal.
|
|
49
|
+
* - `Expired`: stored `Active` with an `expiry` at or before the current
|
|
50
|
+
* block time. Never stored.
|
|
45
51
|
*/
|
|
46
52
|
export enum ProposalStatus {
|
|
47
53
|
Inactive,
|
|
@@ -52,69 +58,30 @@ module ProposalManager {
|
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
/**
|
|
55
|
-
* @description
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* - `Contract`: a contract address. Accepted by both.
|
|
60
|
-
*
|
|
61
|
-
* @warning `toShieldedRecipient` rejects `UnshieldedUser` and
|
|
62
|
-
* `toUnshieldedRecipient` rejects `ShieldedUser`, both at execution rather
|
|
63
|
-
* than at creation. That mismatch is the hazard noted in `_createProposal`.
|
|
64
|
-
*/
|
|
65
|
-
export enum RecipientKind {
|
|
66
|
-
ShieldedUser,
|
|
67
|
-
UnshieldedUser,
|
|
68
|
-
Contract
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* @description A transfer target.
|
|
73
|
-
*
|
|
74
|
-
* @warning `address` is raw 32 bytes. `kind` alone decides whether they are
|
|
75
|
-
* read as a Zswap coin public key, a user address, or a contract address.
|
|
76
|
-
*/
|
|
77
|
-
export struct Recipient {
|
|
78
|
-
kind: RecipientKind,
|
|
79
|
-
address: Bytes<32>
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* @description A proposal. `state` holds both the lifecycle stage and the
|
|
84
|
-
* expiry in a single field:
|
|
61
|
+
* @description A proposal. `status` is the stored lifecycle stage and
|
|
62
|
+
* `expiry` is the deadline, as seconds since the Unix epoch. Reaching a
|
|
63
|
+
* terminal stage changes `status` only, so an executed or cancelled proposal
|
|
64
|
+
* keeps the deadline it was created with.
|
|
85
65
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* proposal is active while the block time is below it and expired
|
|
90
|
-
* afterwards.
|
|
91
|
-
*
|
|
92
|
-
* `_createProposal` rejects an `expiry` in the reserved range, so a proposal
|
|
93
|
-
* that has not reached a terminal state always has `state` greater than
|
|
94
|
-
* `cancelledState()`. Note that `Expired` is NOT terminal: a lapsed proposal
|
|
95
|
-
* still carries its original deadline, which is what tells a reader when it
|
|
96
|
-
* lapsed.
|
|
97
|
-
*
|
|
98
|
-
* @warning In the deadline range, `state` marks when a proposal STOPS being
|
|
99
|
-
* executable, not when it starts: it is executable while `blockTime < state`
|
|
100
|
-
* and inert once `blockTime >= state`.
|
|
66
|
+
* @warning `expiry` marks when a proposal STOPS being executable, not when it
|
|
67
|
+
* starts: an `Active` proposal is executable while `blockTime < expiry` and
|
|
68
|
+
* inert once `blockTime >= expiry`.
|
|
101
69
|
*
|
|
102
70
|
* @notice This module implements no execution delay. `expiry` bounds how long
|
|
103
71
|
* a proposal stays valid; it does not impose a minimum wait before it may be
|
|
104
72
|
* executed. A consumer that needs a delay must compose one.
|
|
105
73
|
*
|
|
106
|
-
* @notice
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* hardcoding them. A state-reading accessor will be revisited once contract-to-contract
|
|
111
|
-
* interactions are available.
|
|
74
|
+
* @notice `_proposals` is exported ledger state and readable without a proof.
|
|
75
|
+
* An off-chain reader takes `status` as stored and, when it is `Active`,
|
|
76
|
+
* compares `expiry` with the current block time. A state-reading accessor
|
|
77
|
+
* will be revisited once contract-to-contract interactions are available.
|
|
112
78
|
*/
|
|
113
79
|
export struct Proposal {
|
|
114
80
|
to: Recipient,
|
|
115
81
|
color: Bytes<32>,
|
|
116
82
|
amount: Uint<128>,
|
|
117
|
-
|
|
83
|
+
expiry: Uint<64>,
|
|
84
|
+
status: ProposalStatus
|
|
118
85
|
}
|
|
119
86
|
|
|
120
87
|
// ─── State ──────────────────────────────────────────────────────
|
|
@@ -136,43 +103,23 @@ module ProposalManager {
|
|
|
136
103
|
|
|
137
104
|
/**
|
|
138
105
|
* @description Proposals by id, keyed by the value `_nextProposalId` produced
|
|
139
|
-
* at creation. Each entry carries the recipient, token color, amount,
|
|
140
|
-
*
|
|
106
|
+
* at creation. Each entry carries the recipient, token color, amount, expiry,
|
|
107
|
+
* and lifecycle status documented on `Proposal`.
|
|
141
108
|
* @type {Uint<64>} id - The proposal identifier, issued by `_nextProposalId`.
|
|
142
109
|
* @type {Proposal} proposal - The proposal record.
|
|
143
110
|
* @type {Map<id, proposal>}
|
|
144
111
|
* @type {Map<Uint<64>, Proposal>} _proposals
|
|
145
112
|
*
|
|
146
|
-
* @notice Entries are never deleted. Reaching a terminal
|
|
147
|
-
* `
|
|
113
|
+
* @notice Entries are never deleted. Reaching a terminal stage overwrites
|
|
114
|
+
* `status` in place so the map only grows, and no circuit here prunes it.
|
|
148
115
|
* A consumer that needs cleanup must compose its own.
|
|
149
116
|
*
|
|
150
117
|
* @notice Readable off-chain without a proof so integrators read this map
|
|
151
|
-
* directly rather than calling a circuit.
|
|
152
|
-
*
|
|
118
|
+
* directly rather than calling a circuit. See `Proposal` for how `status`
|
|
119
|
+
* and `expiry` combine.
|
|
153
120
|
*/
|
|
154
121
|
export ledger _proposals: Map<Uint<64>, Proposal>;
|
|
155
122
|
|
|
156
|
-
// ─── State Sentinels ────────────────────────────────────────────
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* @description The `Proposal.state` value marking a proposal as executed.
|
|
160
|
-
*
|
|
161
|
-
* @returns {Uint<64>} 1.
|
|
162
|
-
*/
|
|
163
|
-
export pure circuit executedState(): Uint<64> {
|
|
164
|
-
return 1;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* @description The `Proposal.state` value marking a proposal as cancelled.
|
|
169
|
-
*
|
|
170
|
-
* @returns {Uint<64>} 2.
|
|
171
|
-
*/
|
|
172
|
-
export pure circuit cancelledState(): Uint<64> {
|
|
173
|
-
return 2;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
123
|
// ─── Recipient Helpers ──────────────────────────────────────────
|
|
177
124
|
|
|
178
125
|
/**
|
|
@@ -278,8 +225,8 @@ module ProposalManager {
|
|
|
278
225
|
|
|
279
226
|
/**
|
|
280
227
|
* @description Asserts that a proposal exists and is still executable. The two
|
|
281
|
-
* checks are separate: a
|
|
282
|
-
*
|
|
228
|
+
* checks are separate: a `status` other than `Active` fails the first, and a
|
|
229
|
+
* deadline that has passed fails the second, so the error message
|
|
283
230
|
* distinguishes the two.
|
|
284
231
|
*
|
|
285
232
|
* @notice Expiry is terminal, unlike a conflicting read. A tx rejected
|
|
@@ -290,10 +237,10 @@ module ProposalManager {
|
|
|
290
237
|
* Requirements:
|
|
291
238
|
*
|
|
292
239
|
* - Proposal must exist.
|
|
293
|
-
* - Proposal must
|
|
240
|
+
* - Proposal `status` must be `Active`.
|
|
294
241
|
* - Proposal must not have expired.
|
|
295
242
|
*
|
|
296
|
-
* @constraints k=
|
|
243
|
+
* @constraints k=7, rows=103
|
|
297
244
|
*
|
|
298
245
|
* @param {Uint<64>} id - The proposal ID.
|
|
299
246
|
*
|
|
@@ -302,13 +249,13 @@ module ProposalManager {
|
|
|
302
249
|
export circuit assertProposalActive(id: Uint<64>): [] {
|
|
303
250
|
assertProposalExists(id);
|
|
304
251
|
|
|
305
|
-
const
|
|
252
|
+
const proposal = _proposals.lookup(disclose(id));
|
|
306
253
|
assert(
|
|
307
|
-
|
|
254
|
+
proposal.status == ProposalStatus.Active,
|
|
308
255
|
"ProposalManager: proposal not active"
|
|
309
256
|
);
|
|
310
257
|
assert(
|
|
311
|
-
blockTimeLt(
|
|
258
|
+
blockTimeLt(proposal.expiry),
|
|
312
259
|
"ProposalManager: proposal expired"
|
|
313
260
|
);
|
|
314
261
|
}
|
|
@@ -341,11 +288,6 @@ module ProposalManager {
|
|
|
341
288
|
* reintroducing the immortal proposal this field exists to prevent. Consuming
|
|
342
289
|
* contracts that need a bounded lifetime must impose their own cap.
|
|
343
290
|
*
|
|
344
|
-
* @notice `expiry` is also rejected if it falls in the sentinel range, keeping
|
|
345
|
-
* the two domains `state` overlays disjoint. That check is enforced here rather
|
|
346
|
-
* than left to the future check, so disjointness holds for whatever block time
|
|
347
|
-
* the executing environment reports.
|
|
348
|
-
*
|
|
349
291
|
* @notice Access control is NOT enforced here.
|
|
350
292
|
* The consuming contract must gate this behind its own
|
|
351
293
|
* authorization policy.
|
|
@@ -353,10 +295,9 @@ module ProposalManager {
|
|
|
353
295
|
* Requirements:
|
|
354
296
|
*
|
|
355
297
|
* - `amount` must be greater than 0.
|
|
356
|
-
* - `expiry` must be greater than `cancelledState()`.
|
|
357
298
|
* - `expiry` must be in the future.
|
|
358
299
|
*
|
|
359
|
-
* @constraints k=10, rows=
|
|
300
|
+
* @constraints k=10, rows=883
|
|
360
301
|
*
|
|
361
302
|
* @param {Recipient} to - The recipient (constructed via helper circuits).
|
|
362
303
|
* @param {Bytes<32>} color - The token color.
|
|
@@ -373,10 +314,6 @@ module ProposalManager {
|
|
|
373
314
|
expiry: Uint<64>
|
|
374
315
|
): Uint<64> {
|
|
375
316
|
assert(amount > 0, "ProposalManager: zero amount");
|
|
376
|
-
assert(
|
|
377
|
-
expiry > cancelledState(),
|
|
378
|
-
"ProposalManager: expiry in reserved range"
|
|
379
|
-
);
|
|
380
317
|
assert(
|
|
381
318
|
blockTimeLt(disclose(expiry)),
|
|
382
319
|
"ProposalManager: expiry not in the future"
|
|
@@ -391,7 +328,8 @@ module ProposalManager {
|
|
|
391
328
|
to: to,
|
|
392
329
|
color: color,
|
|
393
330
|
amount: amount,
|
|
394
|
-
|
|
331
|
+
expiry: expiry,
|
|
332
|
+
status: ProposalStatus.Active
|
|
395
333
|
}));
|
|
396
334
|
|
|
397
335
|
return id;
|
|
@@ -420,7 +358,8 @@ module ProposalManager {
|
|
|
420
358
|
to: proposal.to,
|
|
421
359
|
color: proposal.color,
|
|
422
360
|
amount: proposal.amount,
|
|
423
|
-
|
|
361
|
+
expiry: proposal.expiry,
|
|
362
|
+
status: ProposalStatus.Cancelled
|
|
424
363
|
});
|
|
425
364
|
}
|
|
426
365
|
|
|
@@ -447,16 +386,17 @@ module ProposalManager {
|
|
|
447
386
|
to: proposal.to,
|
|
448
387
|
color: proposal.color,
|
|
449
388
|
amount: proposal.amount,
|
|
450
|
-
|
|
389
|
+
expiry: proposal.expiry,
|
|
390
|
+
status: ProposalStatus.Executed
|
|
451
391
|
});
|
|
452
392
|
}
|
|
453
393
|
|
|
454
394
|
// ─── View ───────────────────────────────────────────────────────
|
|
455
395
|
|
|
456
396
|
/**
|
|
457
|
-
* @description Returns the full proposal data. The returned `
|
|
458
|
-
*
|
|
459
|
-
* `getProposalStatus` when the
|
|
397
|
+
* @description Returns the full proposal data. The returned `status` is the
|
|
398
|
+
* stored stage, so an `Active` entry may already be past its `expiry`.
|
|
399
|
+
* Prefer `getProposalStatus` when the effective status is what is wanted.
|
|
460
400
|
*
|
|
461
401
|
* @param {Uint<64>} id - The proposal ID.
|
|
462
402
|
*
|
|
@@ -504,9 +444,10 @@ module ProposalManager {
|
|
|
504
444
|
}
|
|
505
445
|
|
|
506
446
|
/**
|
|
507
|
-
* @description Derives the lifecycle status of a proposal
|
|
508
|
-
*
|
|
509
|
-
*
|
|
447
|
+
* @description Derives the effective lifecycle status of a proposal: the
|
|
448
|
+
* stored `status` unless it is `Active`, in which case `expiry` and the
|
|
449
|
+
* current block time decide between `Active` and `Expired`. Returns
|
|
450
|
+
* `Inactive` for an id that was never created rather than failing.
|
|
510
451
|
*
|
|
511
452
|
* @constraints k=9, rows=252
|
|
512
453
|
*
|
|
@@ -519,14 +460,11 @@ module ProposalManager {
|
|
|
519
460
|
return ProposalStatus.Inactive;
|
|
520
461
|
}
|
|
521
462
|
|
|
522
|
-
const
|
|
523
|
-
if (
|
|
524
|
-
return
|
|
525
|
-
}
|
|
526
|
-
if (state == cancelledState()) {
|
|
527
|
-
return ProposalStatus.Cancelled;
|
|
463
|
+
const proposal = _proposals.lookup(disclose(id));
|
|
464
|
+
if (proposal.status != ProposalStatus.Active) {
|
|
465
|
+
return proposal.status;
|
|
528
466
|
}
|
|
529
|
-
if (blockTimeLt(
|
|
467
|
+
if (blockTimeLt(proposal.expiry)) {
|
|
530
468
|
return ProposalStatus.Active;
|
|
531
469
|
}
|
|
532
470
|
return ProposalStatus.Expired;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// OpenZeppelin Compact Contracts v0.4.0 (multisig/RecipientTypes.compact)
|
|
3
|
+
|
|
4
|
+
pragma language_version >= 0.26.0;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @module RecipientTypes
|
|
8
|
+
* @description The transfer-target types shared by the multisig modules.
|
|
9
|
+
*
|
|
10
|
+
* This module holds no ledger state. Importing a module allocates every
|
|
11
|
+
* ledger field it declares, so a contract that needs only the recipient
|
|
12
|
+
* types imports this module rather than `ProposalManager`, which would add
|
|
13
|
+
* a `_nextProposalId` and `_proposals` the contract never writes.
|
|
14
|
+
* `ProposalManager` re-exports both types and provides the typed helper
|
|
15
|
+
* circuits that build and route recipients.
|
|
16
|
+
*/
|
|
17
|
+
module RecipientTypes {
|
|
18
|
+
import CompactStandardLibrary;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @description What a `Recipient`'s bytes are and which rail accepts them.
|
|
22
|
+
*
|
|
23
|
+
* - `ShieldedUser`: a Zswap coin public key. `ProposalManager.toShieldedRecipient` only.
|
|
24
|
+
* - `UnshieldedUser`: a user address. `ProposalManager.toUnshieldedRecipient` only.
|
|
25
|
+
* - `Contract`: a contract address. Accepted by both.
|
|
26
|
+
*
|
|
27
|
+
* @warning `toShieldedRecipient` rejects `UnshieldedUser` and
|
|
28
|
+
* `toUnshieldedRecipient` rejects `ShieldedUser`, both at execution rather
|
|
29
|
+
* than at creation. That mismatch is the hazard noted in
|
|
30
|
+
* `ProposalManager._createProposal`.
|
|
31
|
+
*/
|
|
32
|
+
export enum RecipientKind {
|
|
33
|
+
ShieldedUser,
|
|
34
|
+
UnshieldedUser,
|
|
35
|
+
Contract
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @description A transfer target.
|
|
40
|
+
*
|
|
41
|
+
* @warning `address` is raw 32 bytes. `kind` alone decides whether they are
|
|
42
|
+
* read as a Zswap coin public key, a user address, or a contract address.
|
|
43
|
+
*/
|
|
44
|
+
export struct Recipient {
|
|
45
|
+
kind: RecipientKind,
|
|
46
|
+
address: Bytes<32>
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// OpenZeppelin Compact Contracts v0.4.0 (multisig/Secp256k1EcdsaMultisig.compact)
|
|
3
|
+
|
|
4
|
+
pragma language_version >= 0.26.0;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @module Secp256k1EcdsaMultisig<#t>
|
|
8
|
+
* @description
|
|
9
|
+
* A `t`-of-`n` accountable multisignature over secp256k1 ECDSA: `n` signer
|
|
10
|
+
* commitments are registered at deploy, and a call passes when `t` distinct
|
|
11
|
+
* registered signers have signed the digest. The threshold `t` is the module
|
|
12
|
+
* generic, so it is also the approval count every gated call carries.
|
|
13
|
+
*
|
|
14
|
+
* @dev Notation:
|
|
15
|
+
* - `H(...)`: `persistentHash` (SHA-256), a `Bytes<32>` digest.
|
|
16
|
+
* - `cm = H(pkX, pkY, salt, "Secp256k1EcdsaMultisig:signer")`: a signer
|
|
17
|
+
* commitment, over the affine coordinates of the secp256k1 public key.
|
|
18
|
+
* - `n`: signers registered at `initialize`.
|
|
19
|
+
* - `t`: the threshold and the `assertApprovals` width.
|
|
20
|
+
*
|
|
21
|
+
* @dev Derivations:
|
|
22
|
+
* `H` hashes the `SignerCommitmentInput` struct in Compact's encoding, not a
|
|
23
|
+
* byte concatenation. Do not reimplement it: `calculateSignerId` is the
|
|
24
|
+
* exported pure circuit, and the consumer test suites pin its vectors. A
|
|
25
|
+
* wrong derivation registers commitments no key can satisfy.
|
|
26
|
+
*
|
|
27
|
+
* @dev Parameterization:
|
|
28
|
+
* `t` is fixed at import (`import "./Secp256k1EcdsaMultisig"<2> prefix
|
|
29
|
+
* Secp256k1EcdsaMultisig_;`) and `n` per `initialize<#n>` call. `Signer`
|
|
30
|
+
* enforces `n >= t` at `initialize`. No circuit takes a threshold argument.
|
|
31
|
+
* `t = 0` does not compile.
|
|
32
|
+
*
|
|
33
|
+
* @dev Compatibility:
|
|
34
|
+
* The wire format is the `Secp256k1EcdsaMultisig:signer` domain, the
|
|
35
|
+
* `SignerCommitmentInput` layout, and the salt. Changing any of them orphans
|
|
36
|
+
* every registered signer set. The digest is the consumer's and is not part
|
|
37
|
+
* of this module's format.
|
|
38
|
+
*
|
|
39
|
+
* @notice Privacy:
|
|
40
|
+
* - Public, per tx: the `t` commitments (`disclose(commitment)` in
|
|
41
|
+
* `Signer_assertSigner`), so WHICH signers approved.
|
|
42
|
+
* - Public, as state: `_signers`, `_signerCount`, `_threshold`,
|
|
43
|
+
* `_instanceSalt`. All exported.
|
|
44
|
+
* - Private: public keys and signatures, verified in-circuit and never
|
|
45
|
+
* disclosed; the digest, beyond what the consumer publishes.
|
|
46
|
+
* - Signer identity: keys sit behind `cm` with a public salt, so an observer
|
|
47
|
+
* holding a candidate key can test membership; one without cannot
|
|
48
|
+
* correlate a signer across deployments.
|
|
49
|
+
*
|
|
50
|
+
* @notice Security:
|
|
51
|
+
* - Order: `assertApprovals` runs `Signer_assertThresholdMet(t)` first, so an
|
|
52
|
+
* uninitialized registry fails before any signature work. Then pairwise
|
|
53
|
+
* distinct commitments, then per entry a registered commitment and a valid
|
|
54
|
+
* signature.
|
|
55
|
+
* - Low-s: verification goes through `Ecdsa_secp256k1EcdsaVerifyLowS`, so a
|
|
56
|
+
* high-s encoding is an invalid signature.
|
|
57
|
+
* - Distinct signers: all `t(t-1)/2` pairs are compared, so no key counts
|
|
58
|
+
* twice.
|
|
59
|
+
* - Bounds: `Signer` rejects `t > n` at `initialize`.
|
|
60
|
+
* - Once-only: `initialize` rejects a second call while signers are
|
|
61
|
+
* registered, and writes the `sealed` salt, so it is constructor-only.
|
|
62
|
+
* - Immutable to the deployed circuit set: no signer or threshold change is
|
|
63
|
+
* exported. Losing more than `n - t` keys freezes every gated circuit.
|
|
64
|
+
* - Digest binding is the consumer's: this module verifies signatures over
|
|
65
|
+
* whatever `msgHash` it is handed. Replay, domain and instance binding
|
|
66
|
+
* belong in the digest.
|
|
67
|
+
* - Atomicity: a failed assert rejects the whole tx before any state moves,
|
|
68
|
+
* and the sender pays no fee.
|
|
69
|
+
*
|
|
70
|
+
* @notice Concurrency:
|
|
71
|
+
* - Every read (`_threshold`, `_signers.member(cm)`, `_instanceSalt`) is
|
|
72
|
+
* state written only by `initialize`, so `assertApprovals` and the views
|
|
73
|
+
* commute with everything, including each other.
|
|
74
|
+
*
|
|
75
|
+
* @notice Scope:
|
|
76
|
+
* Registry and verification only: no digest construction, no nonce, no
|
|
77
|
+
* roles, no signer rotation. The consumer builds the digest and gates its
|
|
78
|
+
* circuits with `assertApprovals`.
|
|
79
|
+
*
|
|
80
|
+
* @notice Client-side duties:
|
|
81
|
+
* - Before deploy: pick a random `instanceSalt`, derive each `cm` with
|
|
82
|
+
* `calculateSignerId(pk, salt)`, pass them as `signerCommitments`.
|
|
83
|
+
* - Per call: collect exactly `t` low-s signatures from distinct signers over
|
|
84
|
+
* the consumer's digest, submit keys and signatures in matching `Vector<t>`
|
|
85
|
+
* positions.
|
|
86
|
+
*
|
|
87
|
+
* @dev Performance:
|
|
88
|
+
* - Cost is per entry: one ECDSA verify and one `H` for each of the `t`
|
|
89
|
+
* approvals, plus `t(t-1)/2` `Bytes<32>` compares.
|
|
90
|
+
* - `assertApprovals` (zkir-v3): `t = 1` is k=16, rows=40538; `t = 2` is
|
|
91
|
+
* k=17, rows=64908; `t = 3` is k=17, rows=94995.
|
|
92
|
+
*
|
|
93
|
+
* @dev Composition:
|
|
94
|
+
* - Import this module at exactly one width per contract. Two widths
|
|
95
|
+
* instantiate two `Secp256k1EcdsaMultisig` modules over one shared
|
|
96
|
+
* `Signer<Bytes<32>>` registry, so the second `initialize` trips the
|
|
97
|
+
* once-only guard and that width's `_instanceSalt` is never written.
|
|
98
|
+
* - Do not import `Signer` alongside this module. A direct import
|
|
99
|
+
* instantiates a separate registry that `assertApprovals` never reads.
|
|
100
|
+
*/
|
|
101
|
+
module Secp256k1EcdsaMultisig<#t> {
|
|
102
|
+
import CompactStandardLibrary;
|
|
103
|
+
import "../crypto/Ecdsa" prefix Ecdsa_;
|
|
104
|
+
import "./Signer"<Bytes<32>> prefix Signer_;
|
|
105
|
+
|
|
106
|
+
// Named import/re-export of the registry state only, so a consuming
|
|
107
|
+
// contract's ledger keys read as `_signers` rather than `Signer__signers`.
|
|
108
|
+
// The circuits stay behind the `Signer_` prefix.
|
|
109
|
+
import { _signers, _signerCount, _threshold } from "./Signer"<Bytes<32>>;
|
|
110
|
+
export { _signers, _signerCount, _threshold };
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @description Deployment-supplied salt for signer commitments. Set at
|
|
114
|
+
* initialization and never changed. The registered commitments must have been
|
|
115
|
+
* derived under this same salt.
|
|
116
|
+
*/
|
|
117
|
+
export sealed ledger _instanceSalt: Bytes<32>;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @description Input to `persistentHash` for computing signer commitments.
|
|
121
|
+
*/
|
|
122
|
+
struct SignerCommitmentInput {
|
|
123
|
+
pkX: Bytes<32>,
|
|
124
|
+
pkY: Bytes<32>,
|
|
125
|
+
salt: Bytes<32>,
|
|
126
|
+
domain: Bytes<32>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @description Stores the instance salt and registers the signer set at
|
|
131
|
+
* threshold `t`. Call from the consuming contract's constructor only.
|
|
132
|
+
* Writes the `sealed` salt, so it cannot be reached from an exported
|
|
133
|
+
* circuit. The set is final: signers must be known at deploy and no circuit
|
|
134
|
+
* adds, removes or replaces one afterwards.
|
|
135
|
+
*
|
|
136
|
+
* Requirements:
|
|
137
|
+
*
|
|
138
|
+
* - Can only be called once.
|
|
139
|
+
* - `instanceSalt` should be cryptographically random.
|
|
140
|
+
* - `signerCommitments` must not contain duplicates.
|
|
141
|
+
* - `n` must be at least `t`.
|
|
142
|
+
*
|
|
143
|
+
* @param {Bytes<32>} instanceSalt - Per-deployment salt for commitment derivation.
|
|
144
|
+
* @param {Vector<n, Bytes<32>>} signerCommitments - Hashed signer identities.
|
|
145
|
+
* @returns {[]} Empty tuple.
|
|
146
|
+
*/
|
|
147
|
+
export circuit initialize<#n>(
|
|
148
|
+
instanceSalt: Bytes<32>,
|
|
149
|
+
signerCommitments: Vector<n, Bytes<32>>
|
|
150
|
+
): [] {
|
|
151
|
+
assert(Signer_getSignerCount() == 0, "Secp256k1EcdsaMultisig: signers already registered");
|
|
152
|
+
_instanceSalt = disclose(instanceSalt);
|
|
153
|
+
|
|
154
|
+
for (const commitment of signerCommitments) {
|
|
155
|
+
Signer__addSigner(commitment);
|
|
156
|
+
}
|
|
157
|
+
Signer__changeThreshold(t);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @description Verifies `t` approvals over `msgHash`: distinct registered
|
|
162
|
+
* signers, each with a valid low-s signature.
|
|
163
|
+
*
|
|
164
|
+
* `pubkeys` and `signatures` are parallel: entry `i` of each belongs to the
|
|
165
|
+
* same signer.
|
|
166
|
+
*
|
|
167
|
+
* Requirements:
|
|
168
|
+
*
|
|
169
|
+
* - The module must be initialized.
|
|
170
|
+
* - No two entries may be the same signer.
|
|
171
|
+
* - Every public key must hash to a registered signer commitment.
|
|
172
|
+
* - Every signature must be valid over `msgHash` and use the low-s encoding.
|
|
173
|
+
*
|
|
174
|
+
* @param {Bytes<32>} msgHash - The digest the signers approved.
|
|
175
|
+
* @param {Vector<t, Secp256k1Point>} pubkeys - Public keys of approving signers.
|
|
176
|
+
* @param {Vector<t, Secp256k1EcdsaSignature>} signatures - Signatures over `msgHash`.
|
|
177
|
+
* @returns {[]} Empty tuple.
|
|
178
|
+
*/
|
|
179
|
+
export circuit assertApprovals(
|
|
180
|
+
msgHash: Bytes<32>,
|
|
181
|
+
pubkeys: Vector<t, Secp256k1Point>,
|
|
182
|
+
signatures: Vector<t, Secp256k1EcdsaSignature>
|
|
183
|
+
): [] {
|
|
184
|
+
Signer_assertThresholdMet(t);
|
|
185
|
+
|
|
186
|
+
const commitments = map(signerCommitment, pubkeys);
|
|
187
|
+
|
|
188
|
+
for (const i of 0..t) {
|
|
189
|
+
for (const j of 0..t) {
|
|
190
|
+
if (i < j) {
|
|
191
|
+
assert(commitments[i] != commitments[j], "Secp256k1EcdsaMultisig: duplicate signer");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const i of 0..t) {
|
|
197
|
+
Signer_assertSigner(commitments[i]);
|
|
198
|
+
assert(
|
|
199
|
+
Ecdsa_secp256k1EcdsaVerifyLowS(msgHash, signatures[i], pubkeys[i]),
|
|
200
|
+
"Secp256k1EcdsaMultisig: invalid signature"
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* @description `map` callback. Commits `pk` under the instance salt.
|
|
207
|
+
*
|
|
208
|
+
* @param {Secp256k1Point} pk - The signer's secp256k1 public key.
|
|
209
|
+
* @returns {Bytes<32>} The signer commitment.
|
|
210
|
+
*/
|
|
211
|
+
circuit signerCommitment(pk: Secp256k1Point): Bytes<32> {
|
|
212
|
+
return calculateSignerId(pk, _instanceSalt);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* @description Computes a signer commitment from a secp256k1 public key,
|
|
217
|
+
* as `persistentHash(pkX, pkY, salt, "Secp256k1EcdsaMultisig:signer")`.
|
|
218
|
+
*
|
|
219
|
+
* Pure, so the deployer can derive constructor commitments off-chain the
|
|
220
|
+
* same way the circuits derive them in-circuit.
|
|
221
|
+
*
|
|
222
|
+
* Requirements:
|
|
223
|
+
*
|
|
224
|
+
* - `pk` must not be the secp256k1 identity point, which has no coordinates.
|
|
225
|
+
*
|
|
226
|
+
* @param {Secp256k1Point} pk - The secp256k1 public key.
|
|
227
|
+
* @param {Bytes<32>} salt - The instance salt.
|
|
228
|
+
* @returns {Bytes<32>} The signer commitment.
|
|
229
|
+
*/
|
|
230
|
+
export pure circuit calculateSignerId(
|
|
231
|
+
pk: Secp256k1Point,
|
|
232
|
+
salt: Bytes<32>
|
|
233
|
+
): Bytes<32> {
|
|
234
|
+
return persistentHash<SignerCommitmentInput>(SignerCommitmentInput {
|
|
235
|
+
pkX: secp256k1PointX(pk) as Bytes<32>,
|
|
236
|
+
pkY: secp256k1PointY(pk) as Bytes<32>,
|
|
237
|
+
salt: salt,
|
|
238
|
+
domain: pad(32, "Secp256k1EcdsaMultisig:signer")
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @description Returns the number of registered signers.
|
|
244
|
+
*
|
|
245
|
+
* @constraints k=6, rows=34 (zkir-v3)
|
|
246
|
+
*
|
|
247
|
+
* @returns {Uint<8>} The signer count.
|
|
248
|
+
*/
|
|
249
|
+
export circuit getSignerCount(): Uint<8> {
|
|
250
|
+
return Signer_getSignerCount();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* @description Returns the approval threshold.
|
|
255
|
+
*
|
|
256
|
+
* @constraints k=6, rows=34 (zkir-v3)
|
|
257
|
+
*
|
|
258
|
+
* @returns {Uint<8>} The threshold.
|
|
259
|
+
*/
|
|
260
|
+
export circuit getThreshold(): Uint<8> {
|
|
261
|
+
return Signer_getThreshold();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* @description Returns whether the given commitment is a registered signer.
|
|
266
|
+
*
|
|
267
|
+
* @constraints k=8, rows=129 (zkir-v3)
|
|
268
|
+
*
|
|
269
|
+
* @param {Bytes<32>} commitment - The signer commitment to check.
|
|
270
|
+
* @returns {Boolean} True if registered.
|
|
271
|
+
*/
|
|
272
|
+
export circuit isSigner(commitment: Bytes<32>): Boolean {
|
|
273
|
+
return Signer_isSigner(commitment);
|
|
274
|
+
}
|
|
275
|
+
}
|