Skip to content

Commit a8968e4

Browse files
committed
feat(multisig): verify sigs with secp256k1 ECDSA
Both shielded multisig presets accepted any signature: verification was a `stubVerifySignature` circuit that returned true unconditionally. Toolchain 0.34.0 ships `secp256k1EcdsaVerify`, so the fold callback now performs a real check and the stub is gone from both files. Signer commitments hash the public-key coordinates rather than an opaque `Bytes<64>` key, because `Secp256k1Point` reaches the hash only through the `secp256k1PointX`/`Y` accessors. Existing deployments would need their commitments recomputed. The primitives are only bound under ZKIR v3, so `compile:multisig` passes `--feature-zkir-v3`. `secp256k1EcdsaVerify` accepts high-s signatures and Compact has no ordering on `Secp256k1Scalar`, so low-s is not enforced in circuit. Every digest is nonce-bound and no signature is stored, which makes malleability inert here. Refs: #826
1 parent c86b1dd commit a8968e4

9 files changed

Lines changed: 644 additions & 272 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Changed
1111

12+
- Verify `ShieldedMultiSigV2` and `ShieldedMultiSigV3` approvals with the `secp256k1EcdsaVerify` standard-library primitive, removing `stubVerifySignature` from both presets. `execute` / `mint` / `burn` now take `Vector<2, Secp256k1Point>` public keys and `Vector<2, Secp256k1EcdsaSignature>` signatures, and signer commitments hash the public-key coordinates (`pkX`, `pkY`) instead of a `Bytes<64>` key. These primitives require ZKIR v3, so `compile:multisig` now passes `--feature-zkir-v3`. (#826)
1213
- Upgrade the Compact toolchain and Midnight dependencies: compiler `0.31.0``0.34.0`, `@midnight-ntwrk/compact-runtime` `0.16.0``0.19.0`, `@midnight-ntwrk/ledger-v8` `8.1.0``@midnightntwrk/ledger-v9` `1.0.0-rc.3`, `@midnight-ntwrk/compact-js` `2.5.1``2.5.5-rc.8`, the `midnight-js` packages `4.1.1``5.0.0-beta.7`, and `@openzeppelin/compact-simulator` `^0.3.1``^0.4.0`. Contract `pragma language_version` raised `>= 0.23.0``>= 0.26.0` (the language version shipped with compiler 0.34.0). (#841)
1314

1415
## 0.3.0-alpha.2 (2026-08-11)

contracts/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"compile:access": "compact-compiler --dir access",
2929
"compile:archive": "compact-compiler --dir archive",
3030
"compile:crypto": "compact-compiler --dir crypto",
31-
"compile:multisig": "compact-compiler --dir multisig",
31+
"compile:multisig": "compact-compiler --dir multisig --feature-zkir-v3",
3232
"compile:security": "compact-compiler --dir security",
3333
"compile:token": "compact-compiler --dir token",
3434
"compile:utils": "compact-compiler --dir utils",

contracts/src/multisig/presets/ShieldedMultiSigV2.compact

Lines changed: 34 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,13 @@ export struct VerificationState {
4141

4242
/**
4343
* @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.
44+
* Combines the signer's secp256k1 public-key coordinates with an
45+
* instance-specific salt and domain separator to produce a unique,
46+
* unlinkable commitment.
4647
*/
4748
export struct SignerCommitmentInput {
48-
pk: Bytes<64>,
49+
pkX: Bytes<32>,
50+
pkY: Bytes<32>,
4951
salt: Bytes<32>,
5052
domain: Bytes<32>
5153
}
@@ -62,8 +64,10 @@ ledger _instanceSalt: Bytes<32>;
6264
* a threshold.
6365
*
6466
* Each commitment is computed off-chain as:
65-
* persistentHash(SignerCommitmentInput { pk, instanceSalt, domain })
66-
* where domain is pad(32, "MultiSig:signer:").
67+
* persistentHash(SignerCommitmentInput { pkX, pkY, instanceSalt, domain })
68+
* where domain is pad(32, "MultiSig:signer:"). Use the exported
69+
* `_calculateSignerId` circuit to derive them consistently with
70+
* in-circuit verification.
6771
*
6872
* The instanceSalt should be a random value to prevent the same public
6973
* key from producing the same commitment across different multisig
@@ -127,9 +131,6 @@ export circuit deposit(coin: ShieldedCoinInfo): [] {
127131
* is verified against the message hash. Duplicate signers are rejected
128132
* via inequality check on adjacent commitments.
129133
*
130-
* @notice ECDSA verification is stubbed. Replace stubVerifySignature
131-
* with ecdsaVerify when Compact ECDSA primitives are available.
132-
*
133134
* @notice Duplicate detection via != only works for exactly 2 signers.
134135
* Production contracts with larger signer sets need a different
135136
* uniqueness enforcement mechanism.
@@ -144,17 +145,17 @@ export circuit deposit(coin: ShieldedCoinInfo): [] {
144145
* @param {Proposal_Recipient} to - The recipient.
145146
* @param {Uint<128>} amount - The amount to send.
146147
* @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.
148+
* @param {Vector<2, Secp256k1Point>} pubkeys - Public keys of approving signers.
149+
* @param {Vector<2, Secp256k1EcdsaSignature>} signatures - ECDSA signatures over the operation.
149150
*
150151
* @returns {ShieldedSendResult} The send result including any change.
151152
*/
152153
export circuit execute(
153154
to: Proposal_Recipient,
154155
amount: Uint<128>,
155156
coin: QualifiedShieldedCoinInfo,
156-
pubkeys: Vector<2, Bytes<64>>,
157-
signatures: Vector<2, Bytes<64>>
157+
pubkeys: Vector<2, Secp256k1Point>,
158+
signatures: Vector<2, Secp256k1EcdsaSignature>
158159
): ShieldedSendResult {
159160
// Increment nonce
160161
const currentNonce = _nonce;
@@ -192,16 +193,21 @@ export circuit execute(
192193
* instance salt, checks for duplicates against the previous commitment,
193194
* verifies registry membership, and validates the ECDSA signature.
194195
*
196+
* @notice `secp256k1EcdsaVerify` accepts both low-s and high-s signatures, so a
197+
* third party can maul an approval into a second valid encoding. That is inert
198+
* here: every digest commits to the nonce, so a message authorizes at most one
199+
* operation, and signatures are never stored or used as identifiers.
200+
*
195201
* @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.
202+
* @param {Secp256k1Point} pubkey - The signer's secp256k1 public key.
203+
* @param {Secp256k1EcdsaSignature} signature - The signer's signature over msgHash.
198204
*
199205
* @returns {VerificationState} Updated accumulator.
200206
*/
201207
circuit verifySignature(
202208
state: VerificationState,
203-
pubkey: Bytes<64>,
204-
signature: Bytes<64>
209+
pubkey: Secp256k1Point,
210+
signature: Secp256k1EcdsaSignature
205211
): VerificationState {
206212
const commitment = _calculateSignerId(pubkey, _instanceSalt);
207213

@@ -211,9 +217,7 @@ circuit verifySignature(
211217
// Verify this commitment is a registered signer
212218
Signer_assertSigner(commitment);
213219

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");
220+
assert(secp256k1EcdsaVerify(state.msgHash, signature, pubkey), "Multisig: invalid signature");
217221

218222
return VerificationState {
219223
validCount: state.validCount + 1 as Uint<8>,
@@ -223,44 +227,37 @@ circuit verifySignature(
223227
}
224228

225229
/**
226-
* @description Computes a signer commitment from an ECDSA public key.
230+
* @description Computes a signer commitment from a secp256k1 public key.
227231
*
228-
* The commitment is persistentHash(pk, salt, domain) where:
229-
* - pk: the signer's ECDSA public key (64 bytes)
232+
* The commitment is persistentHash(pkX, pkY, salt, domain) where:
233+
* - pkX/pkY: the signer's secp256k1 public-key coordinates
230234
* - salt: instance-specific random value (prevents cross-contract correlation)
231235
* - domain: "MultiSig:signer:" (domain separation)
232236
*
233237
* This is a pure circuit. It can be called off-chain by the deployer
234238
* to compute commitments for the constructor.
235239
*
236-
* @param {Bytes<64>} pk - The ECDSA public key.
240+
* Requirements:
241+
*
242+
* - `pk` must not be the secp256k1 identity point, which has no coordinates.
243+
*
244+
* @param {Secp256k1Point} pk - The secp256k1 public key.
237245
* @param {Bytes<32>} salt - The instance salt.
238246
*
239247
* @returns {Bytes<32>} The signer commitment.
240248
*/
241249
export pure circuit _calculateSignerId(
242-
pk: Bytes<64>,
250+
pk: Secp256k1Point,
243251
salt: Bytes<32>
244252
): Bytes<32> {
245253
return persistentHash<SignerCommitmentInput>(SignerCommitmentInput {
246-
pk: pk,
254+
pkX: secp256k1PointX(pk) as Bytes<32>,
255+
pkY: secp256k1PointY(pk) as Bytes<32>,
247256
salt: salt,
248257
domain: pad(32, "MultiSig:signer:")
249258
});
250259
}
251260

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-
264261
// ─── View ───────────────────────────────────────────────────────
265262

266263
export circuit getNonce(): Uint<64> {

contracts/src/multisig/presets/ShieldedMultiSigV3.compact

Lines changed: 44 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,11 @@ pragma language_version >= 0.26.0;
3333
* hash prevent signatures for one operation type from being replayed as
3434
* the other.
3535
*
36-
* @notice ECDSA verification is stubbed. Replace `stubVerifySignature` with
37-
* `ecdsaVerify`, and `persistentHash` with `keccak256`, once the Compact
38-
* ECDSA and Keccak primitives are available.
36+
* @notice Threshold authorization is enforced with secp256k1 ECDSA
37+
* (`secp256k1EcdsaVerify`) over `persistentHash` digests.
38+
*
39+
* TODO: switch every digest to `keccak256` to match the custodian's HSM
40+
* signing format (#827).
3941
*/
4042

4143
import CompactStandardLibrary;
@@ -60,11 +62,13 @@ struct VerificationState {
6062

6163
/**
6264
* @description Input to persistentHash for computing signer commitments.
63-
* Combines the ECDSA public key with an instance-specific salt and
64-
* domain separator to produce a unique, unlinkable commitment.
65+
* Combines the signer's secp256k1 public-key coordinates with an
66+
* instance-specific salt and domain separator to produce a unique,
67+
* unlinkable commitment.
6568
*/
6669
struct SignerCommitmentInput {
67-
pk: Bytes<64>,
70+
pkX: Bytes<32>,
71+
pkY: Bytes<32>,
6872
salt: Bytes<32>,
6973
domain: Bytes<32>
7074
}
@@ -83,8 +87,11 @@ export sealed ledger _tokenDomain: Bytes<32>;
8387
* a threshold of 2.
8488
*
8589
* Each commitment is computed off-chain as:
86-
* `persistentHash(SignerCommitmentInput { pk, instanceSalt, domain })`
87-
* where domain is `pad(32, "multisig:signer:")`.
90+
* `persistentHash(SignerCommitmentInput { pkX, pkY, instanceSalt, domain })`
91+
* where `pkX`/`pkY` are the signer's secp256k1 public-key coordinates and
92+
* domain is `pad(32, "multisig:signer:")`. Use the exported
93+
* `_calculateSignerId` circuit to derive them consistently with in-circuit
94+
* verification.
8895
*
8996
* `tokenDomain` is used with `kernel.self()` to derive the token color
9097
* via `tokenType(_tokenDomain, kernel.self())`. Only coins of this color
@@ -136,10 +143,6 @@ constructor(
136143
* after the counter has been incremented, binding each mint's nonce to a
137144
* distinct counter value.
138145
*
139-
* @notice Replace `persistentHash` with `keccak256` and `stubVerifySignature`
140-
* with `ecdsaVerify` once the Compact ECDSA and Keccak primitives are
141-
* available, to match the custodian's HSM signing format.
142-
*
143146
* Requirements:
144147
*
145148
* - Both public keys must hash to registered signer commitments.
@@ -149,14 +152,14 @@ constructor(
149152
*
150153
* @param {Uint<64>} amount - The token amount to mint.
151154
* @param {Either<ZswapCoinPublicKey, ContractAddress>} recipient - The address to receive the minted tokens.
152-
* @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers.
153-
* @param {Vector<2, Bytes<64>>} signatures - ECDSA signatures over the mint hash.
155+
* @param {Vector<2, Secp256k1Point>} pubkeys - Public keys of approving signers.
156+
* @param {Vector<2, Secp256k1EcdsaSignature>} signatures - ECDSA signatures over the mint hash.
154157
*/
155158
export circuit mint(
156159
amount: Uint<64>,
157160
recipient: Either<ZswapCoinPublicKey, ContractAddress>,
158-
pubkeys: Vector<2, Bytes<64>>,
159-
signatures: Vector<2, Bytes<64>>
161+
pubkeys: Vector<2, Secp256k1Point>,
162+
signatures: Vector<2, Secp256k1EcdsaSignature>
160163
): [] {
161164
const opNonce = _counter;
162165
_counter.increment(1);
@@ -211,10 +214,6 @@ export circuit mint(
211214
* The "multisig:burn:" domain prefix ensures burn signatures cannot be replayed
212215
* as mint operations for the same parameters.
213216
*
214-
* @notice Replace `persistentHash` with `keccak256` and `stubVerifySignature`
215-
* with `ecdsaVerify` once the Compact ECDSA and Keccak primitives are
216-
* available, to match the custodian's HSM signing format.
217-
*
218217
* Requirements:
219218
*
220219
* - Both public keys must hash to registered signer commitments.
@@ -226,14 +225,14 @@ export circuit mint(
226225
*
227226
* @param {QualifiedShieldedCoinInfo} coin - The coin to burn (from operator's UTXO pool).
228227
* @param {Uint<64>} amount - The token amount to burn.
229-
* @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers.
230-
* @param {Vector<2, Bytes<64>>} signatures - ECDSA signatures over the burn hash.
228+
* @param {Vector<2, Secp256k1Point>} pubkeys - Public keys of approving signers.
229+
* @param {Vector<2, Secp256k1EcdsaSignature>} signatures - ECDSA signatures over the burn hash.
231230
*/
232231
export circuit burn(
233232
coin: QualifiedShieldedCoinInfo,
234233
amount: Uint<64>,
235-
pubkeys: Vector<2, Bytes<64>>,
236-
signatures: Vector<2, Bytes<64>>
234+
pubkeys: Vector<2, Secp256k1Point>,
235+
signatures: Vector<2, Secp256k1EcdsaSignature>
237236
): [] {
238237
const opNonce = _counter;
239238
_counter.increment(1);
@@ -276,15 +275,20 @@ export circuit burn(
276275
* (e.g. 3-of-N) requires a separate variant with a larger vector and a
277276
* different duplicate-detection mechanism (sorted commitments or bitmap).
278277
*
278+
* @notice `secp256k1EcdsaVerify` accepts both low-s and high-s signatures, so a
279+
* third party can maul an approval into a second valid encoding. That is inert
280+
* here: every digest commits to the counter, so a message authorizes at most
281+
* one operation, and signatures are never stored or used as identifiers.
282+
*
279283
* @param {VerificationState} state - Accumulator threaded through fold.
280-
* @param {Bytes<64>} pubkey - The signer's ECDSA public key.
281-
* @param {Bytes<64>} signature - The signer's signature over msgHash.
284+
* @param {Secp256k1Point} pubkey - The signer's secp256k1 public key.
285+
* @param {Secp256k1EcdsaSignature} signature - The signer's signature over msgHash.
282286
* @returns {VerificationState} Updated accumulator.
283287
*/
284288
circuit verifySignature(
285289
state: VerificationState,
286-
pubkey: Bytes<64>,
287-
signature: Bytes<64>
290+
pubkey: Secp256k1Point,
291+
signature: Secp256k1EcdsaSignature
288292
): VerificationState {
289293
const commitment = _calculateSignerId(pubkey, _instanceSalt);
290294

@@ -293,8 +297,7 @@ circuit verifySignature(
293297

294298
Signer_assertSigner(commitment);
295299

296-
// TODO: Replace with ecdsaVerify + keccak256 when primitives are available
297-
assert(stubVerifySignature(pubkey, state.msgHash, signature), "Multisig: invalid signature");
300+
assert(secp256k1EcdsaVerify(state.msgHash, signature, pubkey), "Multisig: invalid signature");
298301

299302
return VerificationState {
300303
validCount: state.validCount + 1 as Uint<8>,
@@ -304,43 +307,36 @@ circuit verifySignature(
304307
}
305308

306309
/**
307-
* @description Computes a signer commitment from an ECDSA public key.
310+
* @description Computes a signer commitment from a secp256k1 public key.
308311
*
309-
* The commitment is persistentHash(pk, salt, domain) where:
310-
* - pk: the signer's ECDSA public key (64 bytes)
312+
* The commitment is persistentHash(pkX, pkY, salt, domain) where:
313+
* - pkX/pkY: the signer's secp256k1 public-key coordinates
311314
* - salt: instance-specific random value (prevents cross-contract correlation)
312315
* - domain: "multisig:signer:" (domain separation)
313316
*
314317
* Pure circuit — callable off-chain by the deployer to compute
315318
* commitments for the constructor.
316319
*
317-
* @param {Bytes<64>} pk - The ECDSA public key.
320+
* Requirements:
321+
*
322+
* - `pk` must not be the secp256k1 identity point, which has no coordinates.
323+
*
324+
* @param {Secp256k1Point} pk - The secp256k1 public key.
318325
* @param {Bytes<32>} salt - The instance salt.
319326
* @returns {Bytes<32>} The signer commitment.
320327
*/
321328
export pure circuit _calculateSignerId(
322-
pk: Bytes<64>,
329+
pk: Secp256k1Point,
323330
salt: Bytes<32>
324331
): Bytes<32> {
325332
return persistentHash<SignerCommitmentInput>(SignerCommitmentInput {
326-
pk: pk,
333+
pkX: secp256k1PointX(pk) as Bytes<32>,
334+
pkY: secp256k1PointY(pk) as Bytes<32>,
327335
salt: salt,
328336
domain: pad(32, "multisig:signer:")
329337
});
330338
}
331339

332-
/**
333-
* @description Stub for ECDSA signature verification.
334-
* Always returns true. MUST be replaced before any non-test deployment.
335-
*/
336-
circuit stubVerifySignature(
337-
pubkey: Bytes<64>,
338-
msgHash: Bytes<32>,
339-
signature: Bytes<64>
340-
): Boolean {
341-
return true;
342-
}
343-
344340
// ─── View ───────────────────────────────────────────────────────
345341

346342
export circuit getNonce(): Uint<64> {

0 commit comments

Comments
 (0)