@openzeppelin/compact-contracts 0.1.0 → 0.3.0-alpha
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 +15 -16
- package/access/AccessControl.compact +3 -35
- package/access/Ownable.compact +47 -60
- package/access/ShieldedAccessControl.compact +43 -10
- package/access/ZOwnablePK.compact +45 -10
- package/crypto/ElGamal.compact +473 -0
- package/multisig/ForwarderPrivate.compact +229 -0
- package/multisig/ForwarderShielded.compact +149 -0
- package/multisig/ForwarderUnshielded.compact +152 -0
- package/multisig/ProposalManager.compact +328 -0
- package/multisig/ShieldedTreasury.compact +201 -0
- package/multisig/ShieldedTreasuryStateless.compact +76 -0
- package/multisig/Signer.compact +312 -0
- package/multisig/SignerManager.compact +205 -0
- package/multisig/UnshieldedTreasury.compact +114 -0
- package/multisig/presets/ShieldedMultiSig.compact +236 -0
- package/multisig/presets/ShieldedMultiSigV2.compact +280 -0
- package/multisig/presets/ShieldedMultiSigV3.compact +368 -0
- package/multisig/presets/forwarder/ForwarderPrivate.compact +125 -0
- package/multisig/presets/forwarder/ForwarderShielded.compact +62 -0
- package/multisig/presets/forwarder/ForwarderUnshielded.compact +64 -0
- package/package.json +12 -6
- package/security/Allowlist.compact +101 -0
- package/security/Blocklist.compact +99 -0
- package/security/Initializable.compact +18 -2
- package/security/Pausable.compact +2 -2
- package/token/FungibleToken.compact +79 -103
- package/token/MultiToken.compact +67 -93
- package/token/NativeShieldedToken.compact +285 -0
- package/token/NativeShieldedTokenCore.compact +343 -0
- package/token/NativeShieldedTokenFamily.compact +287 -0
- package/token/NonFungibleToken.compact +85 -110
- package/utils/Utils.compact +86 -2
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// OpenZeppelin Compact Contracts v0.3.0-alpha (multisig/presets/ShieldedMultiSig.compact)
|
|
3
|
+
|
|
4
|
+
pragma language_version >= 0.23.0;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @module ShieldedMultiSig
|
|
8
|
+
* @description A shielded multisig preset composing `SignerManager`,
|
|
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 "../SignerManager"<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
|
+
export circuit getProposalRecipient(id: Uint<64>): Proposal_Recipient {
|
|
191
|
+
return Proposal_getProposalRecipient(id);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export circuit getProposalAmount(id: Uint<64>): Uint<128> {
|
|
195
|
+
return Proposal_getProposalAmount(id);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export circuit getProposalColor(id: Uint<64>): Bytes<32> {
|
|
199
|
+
return Proposal_getProposalColor(id);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export circuit getProposalStatus(id: Uint<64>): Proposal_ProposalStatus {
|
|
203
|
+
return Proposal_getProposalStatus(id);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// IShieldedTreasury
|
|
207
|
+
|
|
208
|
+
export circuit getTokenBalance(color: Bytes<32>): Uint<128> {
|
|
209
|
+
return Treasury_getTokenBalance(color);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export circuit getReceivedTotal(color: Bytes<32>): Uint<128> {
|
|
213
|
+
return Treasury_getReceivedTotal(color);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export circuit getSentTotal(color: Bytes<32>): Uint<128> {
|
|
217
|
+
return Treasury_getSentTotal(color);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export circuit getReceivedMinusSent(color: Bytes<32>): Uint<128> {
|
|
221
|
+
return Treasury_getReceivedMinusSent(color);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ISignerManager
|
|
225
|
+
|
|
226
|
+
export circuit getSignerCount(): Uint<8> {
|
|
227
|
+
return Signer_getSignerCount();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export circuit getThreshold(): Uint<8> {
|
|
231
|
+
return Signer_getThreshold();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export circuit isSigner(account: Either<ZswapCoinPublicKey, ContractAddress>): Boolean {
|
|
235
|
+
return Signer_isSigner(account);
|
|
236
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// OpenZeppelin Compact Contracts v0.3.0-alpha (multisig/presets/ShieldedMultiSigV2.compact)
|
|
3
|
+
|
|
4
|
+
pragma language_version >= 0.23.0;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @title ShieldedMultisigV2
|
|
8
|
+
* @description Privacy-preserving 2-of-3 multisig contract.
|
|
9
|
+
*
|
|
10
|
+
* Signer identities are stored as commitments: hashes of ECDSA public
|
|
11
|
+
* keys combined with an instance salt and domain separator. Signature
|
|
12
|
+
* verification happens in a single transaction with no multi-step
|
|
13
|
+
* proposal lifecycle. The contract enforces threshold authorization
|
|
14
|
+
* and replay protection. All other coordination (signature collection,
|
|
15
|
+
* coin selection) happens off-chain.
|
|
16
|
+
*
|
|
17
|
+
* Treasury is fully stateless meaning coin data is not stored on the public ledger.
|
|
18
|
+
* Deposits call receiveShielded only. The operator discovers coin indices
|
|
19
|
+
* through ZswapOutput events from the indexer, constructs QualifiedShieldedCoinInfo
|
|
20
|
+
* off-chain, and provides it as a circuit parameter for spending.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import CompactStandardLibrary;
|
|
24
|
+
|
|
25
|
+
import "../ProposalManager" prefix Proposal_;
|
|
26
|
+
import "../ShieldedTreasuryStateless" prefix Treasury_;
|
|
27
|
+
import "../SignerManager"<Bytes<32>> prefix Signer_;
|
|
28
|
+
|
|
29
|
+
// ─── Types ──────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @description Accumulator for fold-based signature verification.
|
|
33
|
+
* Threads the valid count, previous commitment (for duplicate
|
|
34
|
+
* detection), and message hash through each iteration.
|
|
35
|
+
*/
|
|
36
|
+
export struct VerificationState {
|
|
37
|
+
validCount: Uint<8>,
|
|
38
|
+
prevCommitment: Bytes<32>,
|
|
39
|
+
msgHash: Bytes<32>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @description Input to persistentHash for computing signer commitments.
|
|
44
|
+
* Combines the ECDSA public key with an instance-specific salt and
|
|
45
|
+
* domain separator to produce a unique, unlinkable commitment.
|
|
46
|
+
*/
|
|
47
|
+
export struct SignerCommitmentInput {
|
|
48
|
+
pk: Bytes<64>,
|
|
49
|
+
salt: Bytes<32>,
|
|
50
|
+
domain: Bytes<32>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── State ──────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
ledger _nonce: Counter;
|
|
56
|
+
ledger _instanceSalt: Bytes<32>;
|
|
57
|
+
|
|
58
|
+
// ─── Constructor ────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @description Deploys the multisig with 3 signer commitments and
|
|
62
|
+
* a threshold.
|
|
63
|
+
*
|
|
64
|
+
* Each commitment is computed off-chain as:
|
|
65
|
+
* persistentHash(SignerCommitmentInput { pk, instanceSalt, domain })
|
|
66
|
+
* where domain is pad(32, "MultiSig:signer:").
|
|
67
|
+
*
|
|
68
|
+
* The instanceSalt should be a random value to prevent the same public
|
|
69
|
+
* key from producing the same commitment across different multisig
|
|
70
|
+
* deployments, breaking cross-contract signer correlation.
|
|
71
|
+
*
|
|
72
|
+
* Requirements:
|
|
73
|
+
*
|
|
74
|
+
* - `thresh` must be > 0 and <= 2 (matches the 2-signature `execute` surface).
|
|
75
|
+
* - `signerCommitments` must not contain duplicates.
|
|
76
|
+
* - `instanceSalt` should be cryptographically random.
|
|
77
|
+
*
|
|
78
|
+
* @param {Bytes<32>} instanceSalt - Random salt for commitment derivation.
|
|
79
|
+
* @param {Vector<3, Bytes<32>>} signerCommitments - Hashed signer identities.
|
|
80
|
+
* @param {Uint<8>} thresh - Minimum approvals required.
|
|
81
|
+
*/
|
|
82
|
+
constructor(
|
|
83
|
+
instanceSalt: Bytes<32>,
|
|
84
|
+
signerCommitments: Vector<3, Bytes<32>>,
|
|
85
|
+
thresh: Uint<8>,
|
|
86
|
+
) {
|
|
87
|
+
assert(
|
|
88
|
+
thresh <= 2,
|
|
89
|
+
"ShieldedMultiSigV2: threshold cannot exceed 2 (execute verifies at most 2 signatures)"
|
|
90
|
+
);
|
|
91
|
+
_instanceSalt = disclose(instanceSalt);
|
|
92
|
+
Signer_initialize<3>(signerCommitments, thresh);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Deposit ────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @description Receives a shielded coin into the multisig treasury.
|
|
99
|
+
*
|
|
100
|
+
* No access control which allows anyone to deposit. The coin is claimed at the
|
|
101
|
+
* protocol level through receiveShielded. No coin data is stored on the
|
|
102
|
+
* public ledger, preserving full balance privacy.
|
|
103
|
+
*
|
|
104
|
+
* The operator discovers the coin's Merkle tree index by subscribing
|
|
105
|
+
* to ZswapOutput events via the indexer, filtering by contract address,
|
|
106
|
+
* and extracting mt_index. Combined with the known ShieldedCoinInfo,
|
|
107
|
+
* this produces the QualifiedShieldedCoinInfo needed for spending.
|
|
108
|
+
*
|
|
109
|
+
* @param {ShieldedCoinInfo} coin - The incoming shielded coin.
|
|
110
|
+
*/
|
|
111
|
+
export circuit deposit(coin: ShieldedCoinInfo): [] {
|
|
112
|
+
receiveShielded(disclose(coin));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Execute ────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @description Executes a shielded send authorized by threshold signatures.
|
|
119
|
+
*
|
|
120
|
+
* The circuit reads the current nonce from the ledger, increments it,
|
|
121
|
+
* then reconstructs the message hash that signers must have signed
|
|
122
|
+
* off-chain: `persistentHash(nonce, recipient address, coin color, amount)`.
|
|
123
|
+
*
|
|
124
|
+
* Signatures are verified via fold over parallel pubkey and signature
|
|
125
|
+
* vectors. Each public key is hashed with the instance salt to produce
|
|
126
|
+
* a commitment, checked against the signer registry, and the signature
|
|
127
|
+
* is verified against the message hash. Duplicate signers are rejected
|
|
128
|
+
* via inequality check on adjacent commitments.
|
|
129
|
+
*
|
|
130
|
+
* @notice ECDSA verification is stubbed. Replace stubVerifySignature
|
|
131
|
+
* with ecdsaVerify when Compact ECDSA primitives are available.
|
|
132
|
+
*
|
|
133
|
+
* @notice Duplicate detection via != only works for exactly 2 signers.
|
|
134
|
+
* Production contracts with larger signer sets need a different
|
|
135
|
+
* uniqueness enforcement mechanism.
|
|
136
|
+
*
|
|
137
|
+
* Requirements:
|
|
138
|
+
*
|
|
139
|
+
* - Both public keys must hash to registered signer commitments.
|
|
140
|
+
* - Both signatures must be valid over the message hash.
|
|
141
|
+
* - Signers must not be duplicates.
|
|
142
|
+
* - Coin value must be >= amount.
|
|
143
|
+
*
|
|
144
|
+
* @param {Proposal_Recipient} to - The recipient.
|
|
145
|
+
* @param {Uint<128>} amount - The amount to send.
|
|
146
|
+
* @param {QualifiedShieldedCoinInfo} coin - The coin to spend (from operator's pool).
|
|
147
|
+
* @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers.
|
|
148
|
+
* @param {Vector<2, Bytes<64>>} signatures - ECDSA signatures over the operation.
|
|
149
|
+
*
|
|
150
|
+
* @returns {ShieldedSendResult} The send result including any change.
|
|
151
|
+
*/
|
|
152
|
+
export circuit execute(
|
|
153
|
+
to: Proposal_Recipient,
|
|
154
|
+
amount: Uint<128>,
|
|
155
|
+
coin: QualifiedShieldedCoinInfo,
|
|
156
|
+
pubkeys: Vector<2, Bytes<64>>,
|
|
157
|
+
signatures: Vector<2, Bytes<64>>
|
|
158
|
+
): ShieldedSendResult {
|
|
159
|
+
// Increment nonce
|
|
160
|
+
const currentNonce = _nonce;
|
|
161
|
+
_nonce.increment(1);
|
|
162
|
+
|
|
163
|
+
// Construct message hash
|
|
164
|
+
const msgHash = persistentHash<Vector<4, Bytes<32>>>([
|
|
165
|
+
currentNonce as Bytes<32>,
|
|
166
|
+
to.address,
|
|
167
|
+
coin.color,
|
|
168
|
+
amount as Bytes<32>
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
// Verify signatures via fold over parallel vectors
|
|
172
|
+
const initialState = VerificationState {
|
|
173
|
+
validCount: 0 as Uint<8>,
|
|
174
|
+
prevCommitment: pad(32, ""),
|
|
175
|
+
msgHash: msgHash
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const finalState = fold(verifySignature, initialState, pubkeys, signatures);
|
|
179
|
+
Signer_assertThresholdMet(finalState.validCount);
|
|
180
|
+
|
|
181
|
+
// Execute transfer
|
|
182
|
+
const normalizedRecipient = Proposal_toShieldedRecipient(to);
|
|
183
|
+
return Treasury__send(coin, normalizedRecipient, amount);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── Signature Verification ─────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @description Fold callback. Verifies one signer's approval.
|
|
190
|
+
*
|
|
191
|
+
* Computes the signer's commitment from their public key and the
|
|
192
|
+
* instance salt, checks for duplicates against the previous commitment,
|
|
193
|
+
* verifies registry membership, and validates the ECDSA signature.
|
|
194
|
+
*
|
|
195
|
+
* @param {VerificationState} state - Accumulator threaded through fold.
|
|
196
|
+
* @param {Bytes<64>} pubkey - The signer's ECDSA public key.
|
|
197
|
+
* @param {Bytes<64>} signature - The signer's signature over msgHash.
|
|
198
|
+
*
|
|
199
|
+
* @returns {VerificationState} Updated accumulator.
|
|
200
|
+
*/
|
|
201
|
+
circuit verifySignature(
|
|
202
|
+
state: VerificationState,
|
|
203
|
+
pubkey: Bytes<64>,
|
|
204
|
+
signature: Bytes<64>
|
|
205
|
+
): VerificationState {
|
|
206
|
+
const commitment = _calculateSignerId(pubkey, _instanceSalt);
|
|
207
|
+
|
|
208
|
+
// Duplicate detection — sufficient for 2 signers only
|
|
209
|
+
assert(commitment != state.prevCommitment, "Multisig: duplicate signer");
|
|
210
|
+
|
|
211
|
+
// Verify this commitment is a registered signer
|
|
212
|
+
Signer_assertSigner(commitment);
|
|
213
|
+
|
|
214
|
+
// TODO: Replace with actual ECDSA primitive when available
|
|
215
|
+
// assert(ecdsaVerify(pubkey, state.msgHash, signature), "Multisig: invalid signature");
|
|
216
|
+
assert(stubVerifySignature(pubkey, state.msgHash, signature), "Multisig: invalid signature");
|
|
217
|
+
|
|
218
|
+
return VerificationState {
|
|
219
|
+
validCount: state.validCount + 1 as Uint<8>,
|
|
220
|
+
prevCommitment: commitment,
|
|
221
|
+
msgHash: state.msgHash
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* @description Computes a signer commitment from an ECDSA public key.
|
|
227
|
+
*
|
|
228
|
+
* The commitment is persistentHash(pk, salt, domain) where:
|
|
229
|
+
* - pk: the signer's ECDSA public key (64 bytes)
|
|
230
|
+
* - salt: instance-specific random value (prevents cross-contract correlation)
|
|
231
|
+
* - domain: "MultiSig:signer:" (domain separation)
|
|
232
|
+
*
|
|
233
|
+
* This is a pure circuit. It can be called off-chain by the deployer
|
|
234
|
+
* to compute commitments for the constructor.
|
|
235
|
+
*
|
|
236
|
+
* @param {Bytes<64>} pk - The ECDSA public key.
|
|
237
|
+
* @param {Bytes<32>} salt - The instance salt.
|
|
238
|
+
*
|
|
239
|
+
* @returns {Bytes<32>} The signer commitment.
|
|
240
|
+
*/
|
|
241
|
+
export pure circuit _calculateSignerId(
|
|
242
|
+
pk: Bytes<64>,
|
|
243
|
+
salt: Bytes<32>
|
|
244
|
+
): Bytes<32> {
|
|
245
|
+
return persistentHash<SignerCommitmentInput>(SignerCommitmentInput {
|
|
246
|
+
pk: pk,
|
|
247
|
+
salt: salt,
|
|
248
|
+
domain: pad(32, "MultiSig:signer:")
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* @description Stub for ECDSA signature verification.
|
|
254
|
+
* Always returns true. MUST be replaced before any non-test deployment.
|
|
255
|
+
*/
|
|
256
|
+
circuit stubVerifySignature(
|
|
257
|
+
pubkey: Bytes<64>,
|
|
258
|
+
msgHash: Bytes<32>,
|
|
259
|
+
signature: Bytes<64>
|
|
260
|
+
): Boolean {
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ─── View ───────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
export circuit getNonce(): Uint<64> {
|
|
267
|
+
return _nonce;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export circuit getSignerCount(): Uint<8> {
|
|
271
|
+
return Signer_getSignerCount();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export circuit getThreshold(): Uint<8> {
|
|
275
|
+
return Signer_getThreshold();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export circuit isSigner(commitment: Bytes<32>): Boolean {
|
|
279
|
+
return Signer_isSigner(commitment);
|
|
280
|
+
}
|