@openzeppelin/compact-contracts 0.4.0-alpha.1 → 0.4.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +9 -5
  2. package/access/AccessControl.compact +13 -13
  3. package/access/Ownable.compact +15 -9
  4. package/access/ShieldedAccessControl.compact +25 -1
  5. package/access/ZOwnablePK.compact +8 -8
  6. package/crypto/EcdhMask.compact +1 -1
  7. package/crypto/Ecdsa.compact +4 -4
  8. package/crypto/Eip712.compact +168 -0
  9. package/crypto/ElGamal.compact +44 -3
  10. package/multisig/EcdsaSignerManager.compact +32 -15
  11. package/multisig/ForwarderPrivate.compact +11 -5
  12. package/multisig/ForwarderShielded.compact +2 -2
  13. package/multisig/ForwarderUnshielded.compact +2 -2
  14. package/multisig/ProposalManager.compact +194 -22
  15. package/multisig/ShieldedTreasury.compact +28 -1
  16. package/multisig/ShieldedTreasuryStateless.compact +1 -1
  17. package/multisig/Signer.compact +27 -79
  18. package/multisig/UnshieldedTreasury.compact +9 -1
  19. package/multisig/{presets/forwarder/ForwarderPrivate.compact → examples/ForwarderPrivateExample.compact} +3 -3
  20. package/multisig/{presets/forwarder/ForwarderShielded.compact → examples/ForwarderShieldedExample.compact} +3 -3
  21. package/multisig/{presets/forwarder/ForwarderUnshielded.compact → examples/ForwarderUnshieldedExample.compact} +3 -3
  22. package/multisig/examples/NativeShieldedTokenIssuerExample.compact +143 -0
  23. package/multisig/examples/ShieldedMultiSigV2Example.compact +77 -0
  24. package/multisig/presets/NativeShieldedTokenIssuer.compact +557 -0
  25. package/multisig/presets/ShieldedMultiSigV2.compact +251 -188
  26. package/package.json +15 -11
  27. package/security/Allowlist.compact +5 -5
  28. package/security/Blocklist.compact +5 -5
  29. package/security/Initializable.compact +8 -4
  30. package/security/Pausable.compact +10 -6
  31. package/token/ConfidentialFungibleToken.compact +337 -79
  32. package/token/FungibleToken.compact +35 -21
  33. package/token/MultiToken.compact +15 -15
  34. package/token/NativeShieldedToken.compact +65 -16
  35. package/token/NativeShieldedTokenCore.compact +57 -15
  36. package/token/NativeShieldedTokenFamily.compact +66 -16
  37. package/token/NonFungibleToken.compact +83 -34
  38. package/token/extensions/ConfidentialFungibleTokenPublicSupply.compact +11 -6
  39. package/token/extensions/NativeShieldedTokenDerivedNonce.compact +2 -2
  40. package/token/extensions/NativeShieldedTokenFamilyPublicSupply.compact +7 -7
  41. package/token/extensions/NativeShieldedTokenPublicSupply.compact +7 -7
  42. package/token/extensions/NativeShieldedTokenPublicSupplyCore.compact +5 -5
  43. package/utils/EvmAbi.compact +134 -0
  44. package/utils/Utils.compact +11 -1
  45. package/multisig/presets/ShieldedMultiSig.compact +0 -227
  46. package/multisig/presets/ShieldedMultiSigV3.compact +0 -311
@@ -0,0 +1,134 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // OpenZeppelin Compact Contracts v0.4.0-alpha.3 (utils/EvmAbi.compact)
3
+
4
+ pragma language_version >= 0.26.0;
5
+
6
+ /**
7
+ * @module EvmAbi
8
+ * @description Ethereum ABI word encoding, for messages that must hash
9
+ * identically in Compact and on the EVM.
10
+ *
11
+ * A contract verifying signatures produced by EVM signing infrastructure has
12
+ * to hash the exact bytes the signer hashed. `keccak256<T>(value)` hashes
13
+ * Compact's binary representation of `T`, which coincides with
14
+ * `abi.encode` for some types and diverges for others:
15
+ *
16
+ * - `Bytes<n>` is emitted as exactly `n` bytes. So `Bytes<32>` already *is* an
17
+ * EVM ABI word, and `keccak256<Vector<n, Bytes<32>>>(words)` is byte-identical
18
+ * to Solidity's `keccak256(abi.encode(word0, ..., wordN))`. Narrower types
19
+ * are emitted at their own width rather than padded to 32, which is what
20
+ * makes `abi.encodePacked`-style layouts expressible too.
21
+ * - Integers cast with `as Bytes<n>` are emitted **little-endian**, and a
22
+ * `Uint<64>` occupies 8 bytes rather than a full word. `abi.encode(uint256)`
23
+ * is a big-endian 32-byte word, so an integer cast directly into a message
24
+ * will not match what an EVM signer produced.
25
+ * - `Boolean` is emitted as a single byte, where `abi.encode(bool)` is a full
26
+ * word holding 0 or 1 in its least significant byte.
27
+ *
28
+ * This module supplies the conversions that diverge, so a message can be
29
+ * assembled as a `Vector<n, Bytes<32>>` of EVM ABI words and hashed with
30
+ * `keccak256` to reproduce an EVM digest exactly.
31
+ *
32
+ * @notice These are encoding primitives only. The choice of signing envelope
33
+ * (a raw digest, EIP-191, or EIP-712 typed data) belongs to the contract that
34
+ * owns the message.
35
+ *
36
+ * @notice The module itself needs no `keccak256`, so it compiles on either
37
+ * ZKIR backend and `compile:utils` can keep emitting v2. Every `@constraints`
38
+ * below is a v3 figure, and tagged as such.
39
+ */
40
+ module EvmAbi {
41
+ import CompactStandardLibrary;
42
+
43
+ /**
44
+ * @description Encodes a `Uint<8>` as an `abi.encode(uint8)` word: the
45
+ * value in the least significant byte, zero elsewhere.
46
+ *
47
+ * @dev No reversal or decomposition is needed -- a `Uint<8>` is already a
48
+ * single byte so this is the cheapest of the integer encoders. It is
49
+ * also how a Compact `enum` should reach a digest, matching Solidity,
50
+ * where an enum ABI-encodes as its `uint8` ordinal: `uint8Word(kind as Uint<8>)`.
51
+ *
52
+ * @constraints k=6, rows=55 (zkir-v3)
53
+ *
54
+ * @param {Uint<8>} value - The value to encode.
55
+ * @returns {Bytes<32>} The value as an EVM ABI word.
56
+ */
57
+ export pure circuit uint8Word(value: Uint<8>): Bytes<32> {
58
+ const word: Vector<32, Uint<8>> = [
59
+ 0, 0, 0, 0, 0, 0, 0, 0,
60
+ 0, 0, 0, 0, 0, 0, 0, 0,
61
+ 0, 0, 0, 0, 0, 0, 0, 0,
62
+ 0, 0, 0, 0, 0, 0, 0, value
63
+ ];
64
+
65
+ return word as Bytes<32>;
66
+ }
67
+
68
+ /**
69
+ * @description Encodes a `Uint<64>` as an `abi.encode(uint256)` word: the
70
+ * value big-endian in the low 8 bytes, zero-padded on the left.
71
+ *
72
+ * @dev The `Uint<64> as Bytes<8>` cast yields the value little-endian, so
73
+ * the bytes are reversed into place. Only 8 bytes are decomposed rather
74
+ * than 32; the wider cast costs roughly four times as many constraints for
75
+ * an identical result.
76
+ *
77
+ * @constraints k=11, rows=1348 (zkir-v3)
78
+ *
79
+ * @param {Uint<64>} value - The value to encode.
80
+ * @returns {Bytes<32>} The value as a big-endian EVM ABI word.
81
+ */
82
+ export pure circuit uint64Word(value: Uint<64>): Bytes<32> {
83
+ const le = (value as Bytes<8>) as Vector<8, Uint<8>>;
84
+
85
+ const word: Vector<32, Uint<8>> = [
86
+ 0, 0, 0, 0, 0, 0, 0, 0,
87
+ 0, 0, 0, 0, 0, 0, 0, 0,
88
+ 0, 0, 0, 0, 0, 0, 0, 0,
89
+ le[7], le[6], le[5], le[4], le[3], le[2], le[1], le[0]
90
+ ];
91
+
92
+ return word as Bytes<32>;
93
+ }
94
+
95
+ /**
96
+ * @description Encodes a `Uint<128>` as an `abi.encode(uint256)` word: the
97
+ * value big-endian in the low 16 bytes, zero-padded on the left.
98
+ *
99
+ * @dev Decomposes 16 bytes rather than 32, for the reason given on
100
+ * `uint64Word`. Costs roughly twice `uint64Word`, so prefer the narrower
101
+ * encoder when the value genuinely fits in 64 bits. The width here is
102
+ * the value's, not the word's.
103
+ *
104
+ * @constraints k=12, rows=2503 (zkir-v3)
105
+ *
106
+ * @param {Uint<128>} value - The value to encode.
107
+ * @returns {Bytes<32>} The value as a big-endian EVM ABI word.
108
+ */
109
+ export pure circuit uint128Word(value: Uint<128>): Bytes<32> {
110
+ const le = (value as Bytes<16>) as Vector<16, Uint<8>>;
111
+
112
+ const word: Vector<32, Uint<8>> = [
113
+ 0, 0, 0, 0, 0, 0, 0, 0,
114
+ 0, 0, 0, 0, 0, 0, 0, 0,
115
+ le[15], le[14], le[13], le[12], le[11], le[10], le[9], le[8],
116
+ le[7], le[6], le[5], le[4], le[3], le[2], le[1], le[0]
117
+ ];
118
+
119
+ return word as Bytes<32>;
120
+ }
121
+
122
+ /**
123
+ * @description Encodes a `Boolean` as an `abi.encode(bool)` word: zero
124
+ * everywhere except the least significant byte, which is 1 for true.
125
+ *
126
+ * @constraints k=6, rows=54 (zkir-v3)
127
+ *
128
+ * @param {Boolean} value - The value to encode.
129
+ * @returns {Bytes<32>} The value as an EVM ABI word.
130
+ */
131
+ export pure circuit boolWord(value: Boolean): Bytes<32> {
132
+ return uint8Word(value ? 1 as Uint<8> : 0 as Uint<8>);
133
+ }
134
+ }
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // OpenZeppelin Compact Contracts v0.4.0-alpha.1 (utils/Utils.compact)
2
+ // OpenZeppelin Compact Contracts v0.4.0-alpha.3 (utils/Utils.compact)
3
3
 
4
4
  pragma language_version >= 0.26.0;
5
5
 
@@ -150,6 +150,16 @@ module Utils {
150
150
  * ## ID Derivation
151
151
  * `accountId = persistentHash(secretKey)`
152
152
  *
153
+ * @dev The absence of a domain-separation tag is DELIBERATE. This identifier
154
+ * is global by design: the same key material yields the same identity in
155
+ * every module deriving one, so a user who wishes to carry one identity
156
+ * across modules can. Modules wanting a per-deployment, unlinkable identity
157
+ * should not use this circuit.
158
+ *
159
+ * @dev NOT FOR PRIVATE DERIVATION. This returns an identifier, not a secret,
160
+ * and the construction is untagged. A circuit deriving a PRIVATE value from a
161
+ * secret MUST use its own domain-separation tag.
162
+ *
153
163
  * @param {Bytes<32>} secretKey - A 32-byte cryptographically secure random value.
154
164
  *
155
165
  * @returns {Bytes<32>} accountId - The computed account identifier.
@@ -1,227 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
- // OpenZeppelin Compact Contracts v0.4.0-alpha.1 (multisig/presets/ShieldedMultiSig.compact)
3
-
4
- pragma language_version >= 0.26.0;
5
-
6
- /**
7
- * @module ShieldedMultiSig
8
- * @description A shielded multisig preset composing `Signer`,
9
- * `ProposalManager`, and `ShieldedTreasury`. Signers approve proposals that
10
- * transfer shielded tokens out of the treasury once the configured threshold
11
- * is met.
12
- *
13
- * @notice Signer identity uses `Either<ZswapCoinPublicKey, ContractAddress>`
14
- * in state for forward compatibility. In the current protocol, only
15
- * `left(ZswapCoinPublicKey)` callers can authenticate — `getCaller()` resolves
16
- * via `ownPublicKey()` and has no way to produce a right-variant today.
17
- * Registering contract-address signers is permitted but those signers cannot
18
- * exercise governance (create/approve/revoke) until contract-to-contract calls
19
- * are supported. Choose your signer set accordingly.
20
- *
21
- * @notice The state shape is deliberately kept broad so that, once
22
- * contract-to-contract calls are supported, `getCaller()` can be swapped via
23
- * a CMA (Contract Maintenance Authorities) circuit upgrade without a state
24
- * migration. Existing deployments would then gain working contract-signer
25
- * authentication.
26
- */
27
-
28
- import CompactStandardLibrary;
29
-
30
- import "../ProposalManager" prefix Proposal_;
31
- import "../ShieldedTreasury" prefix Treasury_;
32
- import "../Signer"<Either<ZswapCoinPublicKey, ContractAddress>> prefix Signer_;
33
-
34
- // ─── State ───────────────────────────────────────────────────────────────
35
-
36
- export ledger _proposalApprovals: Map<Uint<64>, Map<Either<ZswapCoinPublicKey, ContractAddress>, Boolean>>;
37
- export ledger _approvalCount: Map<Uint<64>, Uint<8>>;
38
-
39
- // ─── Constructor ─────────────────────────────────────────────────────────
40
-
41
- constructor(
42
- signers: Vector<3, Either<ZswapCoinPublicKey, ContractAddress>>,
43
- thresh: Uint<8>
44
- ) {
45
- Signer_initialize<3>(signers, thresh);
46
- }
47
-
48
- // ─── Deposit ─────────────────────────────────────────────────────────────
49
-
50
- export circuit deposit(coin: ShieldedCoinInfo): [] {
51
- Treasury__deposit(coin);
52
- }
53
-
54
- // ─── Proposals ───────────────────────────────────────────────────────────
55
-
56
- export circuit createShieldedProposal(
57
- to: Proposal_Recipient,
58
- color: Bytes<32>,
59
- amount: Uint<128>
60
- ): Uint<64> {
61
- const callerPK = getCaller();
62
- Signer_assertSigner(callerPK);
63
-
64
- assert(
65
- to.kind == Proposal_RecipientKind.ShieldedUser
66
- || to.kind == Proposal_RecipientKind.Contract,
67
- "ShieldedMultiSig: recipient must be a shielded user or contract"
68
- );
69
-
70
- return Proposal__createProposal(to, color, amount);
71
- }
72
-
73
- export circuit approveProposal(id: Uint<64>): [] {
74
- // Check if active
75
- Proposal_assertProposalActive(id);
76
-
77
- // Check signer
78
- const callerPK = getCaller();
79
- Signer_assertSigner(callerPK);
80
-
81
- // Check if already approved
82
- assert(!isProposalApprovedBySigner(id, callerPK), "Multisig: already approved");
83
-
84
- // Approve
85
- _approveProposal(id, callerPK);
86
- }
87
-
88
- export circuit revokeApproval(id: Uint<64>): [] {
89
- // Check if active
90
- Proposal_assertProposalActive(id);
91
-
92
- // Check signer
93
- const callerPK = getCaller();
94
- Signer_assertSigner(callerPK);
95
-
96
- // Check has approved
97
- assert(isProposalApprovedBySigner(id, callerPK), "Multisig: not approved");
98
-
99
- // Revoke
100
- _revokeApproval(id, callerPK);
101
- }
102
-
103
- export circuit executeShieldedProposal(
104
- id: Uint<64>,
105
- ): ShieldedSendResult {
106
- // Check if active
107
- Proposal_assertProposalActive(id);
108
-
109
- // Check threshold
110
- const approvalCount = getApprovalCount(id);
111
- Signer_assertThresholdMet(approvalCount);
112
-
113
- // Transfer
114
- const { to, color, amount } = Proposal_getProposal(id);
115
- const result = Treasury__send(
116
- Proposal_toShieldedRecipient(to),
117
- color,
118
- amount,
119
- );
120
-
121
- // Finish lifecycle
122
- Proposal__markExecuted(id);
123
- return result;
124
- }
125
-
126
- // ─── Internal ───────────────────────────────────────────────────────────
127
-
128
- circuit _approveProposal(id: Uint<64>, signer: Either<ZswapCoinPublicKey, ContractAddress>): [] {
129
- if (!_proposalApprovals.member(disclose(id))) {
130
- _proposalApprovals.insert(disclose(id), default<Map<Either<ZswapCoinPublicKey, ContractAddress>, Boolean>>);
131
- }
132
-
133
- _proposalApprovals.lookup(disclose(id)).insert(disclose(signer), disclose(true));
134
-
135
- const newCount = getApprovalCount(id) + 1 as Uint<8>;
136
- _approvalCount.insert(disclose(id), disclose(newCount));
137
- }
138
-
139
- circuit _revokeApproval(id: Uint<64>, signer: Either<ZswapCoinPublicKey, ContractAddress>): [] {
140
- _proposalApprovals.lookup(disclose(id)).remove(disclose(signer));
141
-
142
- const newCount = getApprovalCount(id) - 1 as Uint<8>;
143
- _approvalCount.insert(disclose(id), disclose(newCount));
144
- }
145
-
146
- /**
147
- * @description Returns the caller identity used for signer authentication.
148
- *
149
- * @warning Currently resolves callers via `ownPublicKey()` only, so any signer
150
- * registered as a `right(ContractAddress)` variant cannot authenticate through
151
- * this circuit today. Ledger fields keep the `Either<ZswapCoinPublicKey, ContractAddress>`
152
- * shape so that, once contract-to-contract calls are supported, `getCaller()`
153
- * can be replaced via a CMA (Contract Maintenance Authorities) circuit upgrade
154
- * to detect the contract-call context and return the appropriate variant —
155
- * without a state migration.
156
- *
157
- * @returns {Either<ZswapCoinPublicKey, ContractAddress>} The caller wrapped as a left-variant.
158
- */
159
- circuit getCaller(): Either<ZswapCoinPublicKey, ContractAddress> {
160
- return left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey());
161
- }
162
-
163
- // ─── View ───────────────────────────────────────────────────────────────
164
-
165
- export circuit isProposalApprovedBySigner(
166
- id: Uint<64>,
167
- signer: Either<ZswapCoinPublicKey, ContractAddress>
168
- ): Boolean {
169
- if (!_proposalApprovals.member(disclose(id)) || !_proposalApprovals.lookup(disclose(id)).member(disclose(signer))) {
170
- return false;
171
- }
172
-
173
- return _proposalApprovals.lookup(disclose(id)).lookup(disclose(signer));
174
- }
175
-
176
- export circuit getApprovalCount(id: Uint<64>): Uint<8> {
177
- if (!_approvalCount.member(disclose(id))) {
178
- return 0;
179
- }
180
-
181
- return _approvalCount.lookup(disclose(id));
182
- }
183
-
184
- // IProposalManager
185
-
186
- export circuit getProposal(id: Uint<64>): Proposal_Proposal {
187
- return Proposal_getProposal(id);
188
- }
189
-
190
- // NOTE: the per-field proposal getters (getProposalRecipient / getProposalAmount
191
- // / getProposalColor) and getReceivedMinusSent were dropped to bring the deploy
192
- // transaction under the block-weight limit (the full 19-circuit deploy is
193
- // rejected with "Transaction would exhaust the block limits"). They are all
194
- // redundant: read the proposal fields via `getProposal(id).to / .amount / .color`,
195
- // and the net balance via `getReceivedTotal(color) - getSentTotal(color)`.
196
-
197
- export circuit getProposalStatus(id: Uint<64>): Proposal_ProposalStatus {
198
- return Proposal_getProposalStatus(id);
199
- }
200
-
201
- // IShieldedTreasury
202
-
203
- export circuit getTokenBalance(color: Bytes<32>): Uint<128> {
204
- return Treasury_getTokenBalance(color);
205
- }
206
-
207
- export circuit getReceivedTotal(color: Bytes<32>): Uint<128> {
208
- return Treasury_getReceivedTotal(color);
209
- }
210
-
211
- export circuit getSentTotal(color: Bytes<32>): Uint<128> {
212
- return Treasury_getSentTotal(color);
213
- }
214
-
215
- // ISigner
216
-
217
- export circuit getSignerCount(): Uint<8> {
218
- return Signer_getSignerCount();
219
- }
220
-
221
- export circuit getThreshold(): Uint<8> {
222
- return Signer_getThreshold();
223
- }
224
-
225
- export circuit isSigner(account: Either<ZswapCoinPublicKey, ContractAddress>): Boolean {
226
- return Signer_isSigner(account);
227
- }
@@ -1,311 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
- // OpenZeppelin Compact Contracts v0.4.0-alpha.1 (multisig/presets/ShieldedMultiSigV3.compact)
3
-
4
- pragma language_version >= 0.26.0;
5
-
6
- /**
7
- * @title ShieldedMultiSigV3
8
- * @description Privacy-preserving multisig token contract for tokenised
9
- * deposits. Both minting and burning require threshold ECDSA
10
- * authorization. No single party can unilaterally create or destroy tokens.
11
- *
12
- * Designed with the following features:
13
- * - Supply only enters through authorized mint (no open deposit)
14
- * - Supply only exits through authorized burn (no arbitrary transfers)
15
- * - Non-transferability enforced by absence of transfer/execute circuits
16
- * - Change coins from partial burns handled by the transaction layer
17
- * - Operator discovers coins via ZswapOutput events from the indexer
18
- *
19
- * Uses Midnight's native shielded token primitives:
20
- *
21
- * - `mint` creates a new UTXO via `mintShieldedToken`, addressed to the
22
- * contract. No external coin input; supply is produced on-chain.
23
- * - `burn` consumes a UTXO via `sendShielded` to `shieldedBurnAddress()`. Only
24
- * coins of this contract's token type can be burned. Change is handled
25
- * automatically by the transaction layer.
26
- *
27
- * Signer identities are stored as commitments: hashes of ECDSA public keys
28
- * combined with an instance salt and domain separator. A counter
29
- * provides replay protection, and also feeds `evolveNonce` to produce
30
- * unique coin nonces on each mint.
31
- *
32
- * Operation domain prefixes ("multisig:mint:" / "multisig:burn:") in the message
33
- * hash prevent signatures for one operation type from being replayed as
34
- * the other.
35
- *
36
- * @notice Threshold authorization is enforced with secp256k1 ECDSA
37
- * (`secp256k1EcdsaVerifyLowS`) over `persistentHash` digests, via
38
- * `EcdsaSignerManager`, so a high-s signature is rejected. The approval
39
- * vectors are fixed at `Vector<2, ...>`, so this contract is 2-of-3 only.
40
- *
41
- * TODO: switch every digest to `keccak256` to match the custodian's HSM
42
- * signing format (#827).
43
- */
44
-
45
- import CompactStandardLibrary;
46
-
47
- import "../EcdsaSignerManager" prefix EcdsaSignerManager_;
48
- import "../../utils/Utils" prefix Utils_;
49
- // For testing
50
- export { ZswapCoinPublicKey };
51
-
52
- // ─── State ──────────────────────────────────────────────────────
53
-
54
- export ledger _counter: Counter;
55
- export ledger _coinNonce: Bytes<32>;
56
- export sealed ledger _tokenDomain: Bytes<32>;
57
-
58
- // ─── Constructor ────────────────────────────────────────────────
59
-
60
- /**
61
- * @description Deploys the contract with 3 signer commitments and
62
- * a threshold of 2.
63
- *
64
- * Each commitment is `persistentHash(pkX, pkY, instanceSalt,
65
- * "multisig:signer:")`. Use the exported `_calculateSignerId` circuit to
66
- * derive them consistently with in-circuit verification.
67
- *
68
- * `tokenDomain` is used with `kernel.self()` to derive the token color
69
- * via `tokenType(_tokenDomain, kernel.self())`. Only coins of this color
70
- * can be burned through this contract.
71
- *
72
- * `initCoinNonce` seeds the `evolveNonce` chain used to produce unique
73
- * mint nonces. It must be cryptographically random.
74
- *
75
- * Requirements:
76
- *
77
- * - `signerCommitments` must not contain duplicates.
78
- * - `instanceSalt` and `initCoinNonce` should be cryptographically random.
79
- *
80
- * @param {Bytes<32>} instanceSalt - Random salt for signer commitment derivation.
81
- * @param {Bytes<32>} initCoinNonce - Initial coin nonce seed.
82
- * @param {Bytes<32>} tokenDomain - Domain string used to derive this contract's token color.
83
- * @param {Vector<3, Bytes<32>>} signerCommitments - Hashed signer identities.
84
- */
85
- constructor(
86
- instanceSalt: Bytes<32>,
87
- initCoinNonce: Bytes<32>,
88
- tokenDomain: Bytes<32>,
89
- signerCommitments: Vector<3, Bytes<32>>,
90
- ) {
91
- _coinNonce = disclose(initCoinNonce);
92
- _tokenDomain = disclose(tokenDomain);
93
- EcdsaSignerManager_initialize<3>(instanceSalt, signerCommitments, 2);
94
- }
95
-
96
- // ─── Mint ───────────────────────────────────────────────────────
97
-
98
- /**
99
- * @description Mints a new shielded coin addressed to the specified
100
- * recipient, authorized by threshold signatures.
101
- *
102
- * Creates a new UTXO of this contract's token type via `mintShieldedToken`,
103
- * addressed to the provided recipient. No external coin input is required;
104
- * supply is produced on-chain.
105
- *
106
- * The message hash commits to:
107
- * - operation domain ("multisig:mint:" for burn op replay protection)
108
- * - contract address (cross-instance replay protection)
109
- * - recipient (redirection protection)
110
- * - counter value (replay protection)
111
- * - amount
112
- *
113
- * Coin nonce uniqueness is guaranteed by `evolveNonce(_counter, _coinNonce)`
114
- * after the counter has been incremented, binding each mint's nonce to a
115
- * distinct counter value.
116
- *
117
- * @circuitInfo k=17, rows=84506
118
- *
119
- * Requirements:
120
- *
121
- * - Both public keys must hash to registered signer commitments.
122
- * - Both signatures must be valid over the mint message hash.
123
- * - Signers must not be duplicates.
124
- * - Threshold must be met.
125
- *
126
- * @param {Uint<64>} amount - The token amount to mint.
127
- * @param {Either<ZswapCoinPublicKey, ContractAddress>} recipient - The address to receive the minted tokens.
128
- * @param {Vector<2, Secp256k1Point>} pubkeys - Public keys of approving signers.
129
- * @param {Vector<2, Secp256k1EcdsaSignature>} signatures - ECDSA signatures over the mint hash.
130
- */
131
- export circuit mint(
132
- amount: Uint<64>,
133
- recipient: Either<ZswapCoinPublicKey, ContractAddress>,
134
- pubkeys: Vector<2, Secp256k1Point>,
135
- signatures: Vector<2, Secp256k1EcdsaSignature>
136
- ): [] {
137
- const opNonce = _counter;
138
- _counter.increment(1);
139
-
140
- // Canonicalize recipient to ensure garbage values don't sully the hash
141
- const canonRecipient = Utils_canonicalize<ZswapCoinPublicKey, ContractAddress>(recipient);
142
- const recipientHash = persistentHash<Either<ZswapCoinPublicKey, ContractAddress>>(canonRecipient);
143
-
144
- const msgHash = persistentHash<Vector<5, Bytes<32>>>([
145
- pad(32, "multisig:mint:"),
146
- kernel.self().bytes,
147
- recipientHash,
148
- opNonce as Bytes<32>,
149
- amount as Bytes<32>
150
- ]);
151
-
152
- EcdsaSignerManager_assertApprovals(msgHash, pubkeys, signatures);
153
-
154
- _coinNonce = evolveNonce(_counter, _coinNonce);
155
- mintShieldedToken(_tokenDomain, disclose(amount), _coinNonce, disclose(canonRecipient));
156
- }
157
-
158
- // ─── Burn ───────────────────────────────────────────────────────
159
-
160
- /**
161
- * @description Burns a shielded coin of this contract's token type,
162
- * authorized by threshold signatures.
163
- *
164
- * Sends the specified amount of the supplied coin to `shieldedBurnAddress()` via
165
- * `sendShielded`. The nullifier is submitted on-chain, permanently marking
166
- * the UTXO as spent.
167
- *
168
- * Change handling is automatic: if `amount < coin.value`, the transaction
169
- * layer creates a change output addressed back to the contract. The operator
170
- * discovers this change coin via `nextZswapLocalState.outputs` in the
171
- * transaction's private result, then discovers its mt_index from the
172
- * indexer events using the standard flow. No contract-level change logic
173
- * is required.
174
- *
175
- * Only coins of this contract's token type can be burned. The operator
176
- * supplies the `QualifiedShieldedCoinInfo` from the off-chain UTXO pool.
177
- *
178
- * The "multisig:burn:" domain prefix ensures burn signatures cannot be replayed
179
- * as mint operations for the same parameters.
180
- *
181
- * @circuitInfo k=17, rows=92170
182
- *
183
- * Requirements:
184
- *
185
- * - Both public keys must hash to registered signer commitments.
186
- * - Both signatures must be valid over the burn message hash.
187
- * - Signers must not be duplicates.
188
- * - Threshold must be met.
189
- * - coin.color must equal tokenType(_tokenDomain, kernel.self()).
190
- * - coin.value must be >= amount.
191
- *
192
- * @param {QualifiedShieldedCoinInfo} coin - The coin to burn (from operator's UTXO pool).
193
- * @param {Uint<64>} amount - The token amount to burn.
194
- * @param {Vector<2, Secp256k1Point>} pubkeys - Public keys of approving signers.
195
- * @param {Vector<2, Secp256k1EcdsaSignature>} signatures - ECDSA signatures over the burn hash.
196
- */
197
- export circuit burn(
198
- coin: QualifiedShieldedCoinInfo,
199
- amount: Uint<64>,
200
- pubkeys: Vector<2, Secp256k1Point>,
201
- signatures: Vector<2, Secp256k1EcdsaSignature>
202
- ): [] {
203
- const opNonce = _counter;
204
- _counter.increment(1);
205
-
206
- const msgHash = persistentHash<Vector<4, Bytes<32>>>([
207
- pad(32, "multisig:burn:"),
208
- kernel.self().bytes,
209
- opNonce as Bytes<32>,
210
- amount as Bytes<32>
211
- ]);
212
-
213
- EcdsaSignerManager_assertApprovals(msgHash, pubkeys, signatures);
214
-
215
- assert(coin.color == tokenType(_tokenDomain, kernel.self()), "Multisig: coin not from this contract");
216
- assert(coin.value >= amount, "Multisig: insufficient coin value");
217
-
218
- sendShielded(disclose(coin), shieldedBurnAddress(), disclose(amount));
219
- }
220
-
221
- // ─── Commitments ────────────────────────────────────────────────
222
-
223
- /**
224
- * @description Computes a signer commitment from a secp256k1 public key,
225
- * as `persistentHash(pkX, pkY, salt, "multisig:signer:")`.
226
- *
227
- * Pure, so the deployer can derive constructor commitments off-chain.
228
- *
229
- * Requirements:
230
- *
231
- * - `pk` must not be the secp256k1 identity point, which has no coordinates.
232
- *
233
- * @param {Secp256k1Point} pk - The secp256k1 public key.
234
- * @param {Bytes<32>} salt - The instance salt.
235
- * @returns {Bytes<32>} The signer commitment.
236
- */
237
- export pure circuit _calculateSignerId(
238
- pk: Secp256k1Point,
239
- salt: Bytes<32>
240
- ): Bytes<32> {
241
- return EcdsaSignerManager_calculateSignerId(pk, salt);
242
- }
243
-
244
- // ─── View ───────────────────────────────────────────────────────
245
-
246
- /**
247
- * @description Returns the counter the next mint or burn digest will bind to.
248
- *
249
- * @circuitInfo k=6, rows=34
250
- *
251
- * @returns {Uint<64>} The current counter.
252
- */
253
- export circuit getNonce(): Uint<64> {
254
- return _counter;
255
- }
256
-
257
- /**
258
- * @description Returns the domain separator this contract's token type derives from.
259
- *
260
- * @circuitInfo k=6, rows=57
261
- *
262
- * @returns {Bytes<32>} The token domain.
263
- */
264
- export circuit getTokenDomain(): Bytes<32> {
265
- return _tokenDomain;
266
- }
267
-
268
- /**
269
- * @description Returns this contract's token type.
270
- *
271
- * @circuitInfo k=13, rows=3836
272
- *
273
- * @returns {Bytes<32>} The token type.
274
- */
275
- export circuit getTokenType(): Bytes<32> {
276
- return tokenType(_tokenDomain, kernel.self());
277
- }
278
-
279
- /**
280
- * @description Returns the number of registered signers.
281
- *
282
- * @circuitInfo k=6, rows=34
283
- *
284
- * @returns {Uint<8>} The signer count.
285
- */
286
- export circuit getSignerCount(): Uint<8> {
287
- return EcdsaSignerManager_getSignerCount();
288
- }
289
-
290
- /**
291
- * @description Returns the approval threshold.
292
- *
293
- * @circuitInfo k=6, rows=34
294
- *
295
- * @returns {Uint<8>} The threshold.
296
- */
297
- export circuit getThreshold(): Uint<8> {
298
- return EcdsaSignerManager_getThreshold();
299
- }
300
-
301
- /**
302
- * @description Returns whether the given commitment is a registered signer.
303
- *
304
- * @circuitInfo k=8, rows=129
305
- *
306
- * @param {Bytes<32>} commitment - The signer commitment to check.
307
- * @returns {Boolean} True if registered.
308
- */
309
- export circuit isSigner(commitment: Bytes<32>): Boolean {
310
- return EcdsaSignerManager_isSigner(commitment);
311
- }