Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
"@midnight-ntwrk/midnight-js-protocol": "4.1.1",
"@midnight-ntwrk/midnight-js-types": "4.1.1",
"@midnight-ntwrk/testkit-js": "4.1.1",
"@openzeppelin/compact-simulator": "^0.2.0",
"@openzeppelin/compact-simulator": "portal:../../compact-tools-pub/packages/simulator",
"@tsconfig/node24": "^24.0.4",
"@types/node": "26.1.1",
"@vitest/coverage-v8": "^4.1.10",
Expand Down
63 changes: 27 additions & 36 deletions contracts/src/multisig/presets/ShieldedMultiSigV2.compact
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,13 @@ export struct VerificationState {

/**
* @description Input to persistentHash for computing signer commitments.
* Combines the ECDSA public key with an instance-specific salt and
* domain separator to produce a unique, unlinkable commitment.
* Combines the signer's secp256k1 public-key coordinates (X,Y) with an
* instance-specific salt and domain separator to produce a unique,
* unlinkable commitment.
*/
export struct SignerCommitmentInput {
pk: Bytes<64>,
pkX: Bytes<32>,
pkY: Bytes<32>,
salt: Bytes<32>,
domain: Bytes<32>
}
Expand All @@ -62,8 +64,10 @@ ledger _instanceSalt: Bytes<32>;
* a threshold.
*
* Each commitment is computed off-chain as:
* persistentHash(SignerCommitmentInput { pk, instanceSalt, domain })
* where domain is pad(32, "MultiSig:signer:").
* persistentHash(SignerCommitmentInput { pkX, pkY, instanceSalt, domain })
* where pkX/pkY are the signer's secp256k1 public-key coordinates and
* domain is pad(32, "MultiSig:signer:"). Use the exported `_calculateSignerId`
* circuit to derive these consistently with in-circuit verification.
*
* The instanceSalt should be a random value to prevent the same public
* key from producing the same commitment across different multisig
Expand Down Expand Up @@ -127,8 +131,8 @@ export circuit deposit(coin: ShieldedCoinInfo): [] {
* is verified against the message hash. Duplicate signers are rejected
* via inequality check on adjacent commitments.
*
* @notice ECDSA verification is stubbed. Replace stubVerifySignature
* with ecdsaVerify when Compact ECDSA primitives are available.
* @notice Signatures are verified with secp256k1 ECDSA
* (`secp256k1EcdsaVerify`) over the persistentHash message digest.
*
* @notice Duplicate detection via != only works for exactly 2 signers.
* Production contracts with larger signer sets need a different
Expand All @@ -144,17 +148,17 @@ export circuit deposit(coin: ShieldedCoinInfo): [] {
* @param {Proposal_Recipient} to - The recipient.
* @param {Uint<128>} amount - The amount to send.
* @param {QualifiedShieldedCoinInfo} coin - The coin to spend (from operator's pool).
* @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers.
* @param {Vector<2, Bytes<64>>} signatures - ECDSA signatures over the operation.
* @param {Vector<2, Secp256k1Point>} pubkeys - Public keys of approving signers.
* @param {Vector<2, Secp256k1EcdsaSignature>} signatures - ECDSA signatures over the operation.
*
* @returns {ShieldedSendResult} The send result including any change.
*/
export circuit execute(
to: Proposal_Recipient,
amount: Uint<128>,
coin: QualifiedShieldedCoinInfo,
pubkeys: Vector<2, Bytes<64>>,
signatures: Vector<2, Bytes<64>>
pubkeys: Vector<2, Secp256k1Point>,
signatures: Vector<2, Secp256k1EcdsaSignature>
): ShieldedSendResult {
// Increment nonce
const currentNonce = _nonce;
Expand Down Expand Up @@ -193,15 +197,15 @@ export circuit execute(
* verifies registry membership, and validates the ECDSA signature.
*
* @param {VerificationState} state - Accumulator threaded through fold.
* @param {Bytes<64>} pubkey - The signer's ECDSA public key.
* @param {Bytes<64>} signature - The signer's signature over msgHash.
* @param {Secp256k1Point} pubkey - The signer's secp256k1 public key.
* @param {Secp256k1EcdsaSignature} signature - The signer's ECDSA signature over msgHash.
*
* @returns {VerificationState} Updated accumulator.
*/
circuit verifySignature(
state: VerificationState,
pubkey: Bytes<64>,
signature: Bytes<64>
pubkey: Secp256k1Point,
signature: Secp256k1EcdsaSignature
): VerificationState {
const commitment = _calculateSignerId(pubkey, _instanceSalt);

Expand All @@ -211,9 +215,7 @@ circuit verifySignature(
// Verify this commitment is a registered signer
Signer_assertSigner(commitment);

// TODO: Replace with actual ECDSA primitive when available
// assert(ecdsaVerify(pubkey, state.msgHash, signature), "Multisig: invalid signature");
assert(stubVerifySignature(pubkey, state.msgHash, signature), "Multisig: invalid signature");
assert(secp256k1EcdsaVerify(state.msgHash, signature, pubkey), "Multisig: invalid signature");

return VerificationState {
validCount: state.validCount + 1 as Uint<8>,
Expand All @@ -223,44 +225,33 @@ circuit verifySignature(
}

/**
* @description Computes a signer commitment from an ECDSA public key.
* @description Computes a signer commitment from a secp256k1 public key.
*
* The commitment is persistentHash(pk, salt, domain) where:
* - pk: the signer's ECDSA public key (64 bytes)
* The commitment is persistentHash(pkX, pkY, salt, domain) where:
* - pkX/pkY: the signer's secp256k1 public-key coordinates
* - salt: instance-specific random value (prevents cross-contract correlation)
* - domain: "MultiSig:signer:" (domain separation)
*
* This is a pure circuit. It can be called off-chain by the deployer
* to compute commitments for the constructor.
*
* @param {Bytes<64>} pk - The ECDSA public key.
* @param {Secp256k1Point} pk - The secp256k1 public key.
* @param {Bytes<32>} salt - The instance salt.
*
* @returns {Bytes<32>} The signer commitment.
*/
export pure circuit _calculateSignerId(
pk: Bytes<64>,
pk: Secp256k1Point,
salt: Bytes<32>
): Bytes<32> {
return persistentHash<SignerCommitmentInput>(SignerCommitmentInput {
pk: pk,
pkX: secp256k1PointX(pk) as Bytes<32>,
pkY: secp256k1PointY(pk) as Bytes<32>,
salt: salt,
domain: pad(32, "MultiSig:signer:")
});
}

/**
* @description Stub for ECDSA signature verification.
* Always returns true. MUST be replaced before any non-test deployment.
*/
circuit stubVerifySignature(
pubkey: Bytes<64>,
msgHash: Bytes<32>,
signature: Bytes<64>
): Boolean {
return true;
}

// ─── View ───────────────────────────────────────────────────────

export circuit getNonce(): Uint<64> {
Expand Down
92 changes: 41 additions & 51 deletions contracts/src/multisig/presets/ShieldedMultiSigV3.compact
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ pragma language_version >= 0.23.0;
* hash prevent signatures for one operation type from being replayed as
* the other.
*
* @notice ECDSA verification is stubbed. Replace `stubVerifySignature` with
* `ecdsaVerify`, and `persistentHash` with `keccak256`, once the Compact
* ECDSA and Keccak primitives are available.
* @notice Threshold authorization is enforced with secp256k1 ECDSA
* verification (`secp256k1EcdsaVerify`), and all operation/commitment hashing
* uses `keccak256`.
*/

import CompactStandardLibrary;
Expand All @@ -59,12 +59,14 @@ struct VerificationState {
}

/**
* @description Input to persistentHash for computing signer commitments.
* Combines the ECDSA public key with an instance-specific salt and
* domain separator to produce a unique, unlinkable commitment.
* @description Input to keccak256 for computing signer commitments.
* Combines the signer's secp256k1 public-key coordinates (X,Y) with an
* instance-specific salt and domain separator to produce a unique,
* unlinkable commitment.
*/
struct SignerCommitmentInput {
pk: Bytes<64>,
pkX: Bytes<32>,
pkY: Bytes<32>,
salt: Bytes<32>,
domain: Bytes<32>
}
Expand All @@ -83,8 +85,10 @@ export sealed ledger _tokenDomain: Bytes<32>;
* a threshold of 2.
*
* Each commitment is computed off-chain as:
* `persistentHash(SignerCommitmentInput { pk, instanceSalt, domain })`
* where domain is `pad(32, "multisig:signer:")`.
* `keccak256(SignerCommitmentInput { pkX, pkY, instanceSalt, domain })`
* where `pkX`/`pkY` are the signer's secp256k1 public-key coordinates and
* domain is `pad(32, "multisig:signer:")`. Use the exported `_calculateSignerId`
* circuit to derive these consistently with in-circuit verification.
*
* `tokenDomain` is used with `kernel.self()` to derive the token color
* via `tokenType(_tokenDomain, kernel.self())`. Only coins of this color
Expand Down Expand Up @@ -136,9 +140,8 @@ constructor(
* after the counter has been incremented, binding each mint's nonce to a
* distinct counter value.
*
* @notice Replace `persistentHash` with `keccak256` and `stubVerifySignature`
* with `ecdsaVerify` once the Compact ECDSA and Keccak primitives are
* available, to match the custodian's HSM signing format.
* @notice The message hash is computed with `keccak256` and each
* signature is checked with `secp256k1EcdsaVerify`.
*
* Requirements:
*
Expand All @@ -149,23 +152,23 @@ constructor(
*
* @param {Uint<64>} amount - The token amount to mint.
* @param {Either<ZswapCoinPublicKey, ContractAddress>} recipient - The address to receive the minted tokens.
* @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers.
* @param {Vector<2, Bytes<64>>} signatures - ECDSA signatures over the mint hash.
* @param {Vector<2, Secp256k1Point>} pubkeys - Public keys of approving signers.
* @param {Vector<2, Secp256k1EcdsaSignature>} signatures - ECDSA signatures over the mint hash.
*/
export circuit mint(
amount: Uint<64>,
recipient: Either<ZswapCoinPublicKey, ContractAddress>,
pubkeys: Vector<2, Bytes<64>>,
signatures: Vector<2, Bytes<64>>
pubkeys: Vector<2, Secp256k1Point>,
signatures: Vector<2, Secp256k1EcdsaSignature>
): [] {
const opNonce = _counter;
_counter.increment(1);

// Canonicalize recipient to ensure garbage values don't sully the hash
const canonRecipient = Utils_canonicalize<ZswapCoinPublicKey, ContractAddress>(recipient);
const recipientHash = persistentHash<Either<ZswapCoinPublicKey, ContractAddress>>(canonRecipient);
const recipientHash = keccak256<Either<ZswapCoinPublicKey, ContractAddress>>(canonRecipient);

const msgHash = persistentHash<Vector<5, Bytes<32>>>([
const msgHash = keccak256<Vector<5, Bytes<32>>>([
pad(32, "multisig:mint:"),
kernel.self().bytes,
recipientHash,
Expand Down Expand Up @@ -211,9 +214,8 @@ export circuit mint(
* The "multisig:burn:" domain prefix ensures burn signatures cannot be replayed
* as mint operations for the same parameters.
*
* @notice Replace `persistentHash` with `keccak256` and `stubVerifySignature`
* with `ecdsaVerify` once the Compact ECDSA and Keccak primitives are
* available, to match the custodian's HSM signing format.
* @notice The message hash is computed with `keccak256` and each
* signature is checked with `secp256k1EcdsaVerify`.
*
* Requirements:
*
Expand All @@ -226,19 +228,19 @@ export circuit mint(
*
* @param {QualifiedShieldedCoinInfo} coin - The coin to burn (from operator's UTXO pool).
* @param {Uint<64>} amount - The token amount to burn.
* @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers.
* @param {Vector<2, Bytes<64>>} signatures - ECDSA signatures over the burn hash.
* @param {Vector<2, Secp256k1Point>} pubkeys - Public keys of approving signers.
* @param {Vector<2, Secp256k1EcdsaSignature>} signatures - ECDSA signatures over the burn hash.
*/
export circuit burn(
coin: QualifiedShieldedCoinInfo,
amount: Uint<64>,
pubkeys: Vector<2, Bytes<64>>,
signatures: Vector<2, Bytes<64>>
pubkeys: Vector<2, Secp256k1Point>,
signatures: Vector<2, Secp256k1EcdsaSignature>
): [] {
const opNonce = _counter;
_counter.increment(1);

const msgHash = persistentHash<Vector<4, Bytes<32>>>([
const msgHash = keccak256<Vector<4, Bytes<32>>>([
pad(32, "multisig:burn:"),
kernel.self().bytes,
opNonce as Bytes<32>,
Expand Down Expand Up @@ -277,14 +279,14 @@ export circuit burn(
* different duplicate-detection mechanism (sorted commitments or bitmap).
*
* @param {VerificationState} state - Accumulator threaded through fold.
* @param {Bytes<64>} pubkey - The signer's ECDSA public key.
* @param {Bytes<64>} signature - The signer's signature over msgHash.
* @param {Secp256k1Point} pubkey - The signer's secp256k1 public key.
* @param {Secp256k1EcdsaSignature} signature - The signer's ECDSA signature over msgHash.
* @returns {VerificationState} Updated accumulator.
*/
circuit verifySignature(
state: VerificationState,
pubkey: Bytes<64>,
signature: Bytes<64>
pubkey: Secp256k1Point,
signature: Secp256k1EcdsaSignature
): VerificationState {
const commitment = _calculateSignerId(pubkey, _instanceSalt);

Expand All @@ -293,8 +295,7 @@ circuit verifySignature(

Signer_assertSigner(commitment);

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

return VerificationState {
validCount: state.validCount + 1 as Uint<8>,
Expand All @@ -304,43 +305,32 @@ circuit verifySignature(
}

/**
* @description Computes a signer commitment from an ECDSA public key.
* @description Computes a signer commitment from a secp256k1 public key.
*
* The commitment is persistentHash(pk, salt, domain) where:
* - pk: the signer's ECDSA public key (64 bytes)
* The commitment is keccak256(pkX, pkY, salt, domain) where:
* - pkX/pkY: the signer's secp256k1 public-key coordinates (32 bytes each)
* - salt: instance-specific random value (prevents cross-contract correlation)
* - domain: "multisig:signer:" (domain separation)
*
* Pure circuit — callable off-chain by the deployer to compute
* commitments for the constructor.
*
* @param {Bytes<64>} pk - The ECDSA public key.
* @param {Secp256k1Point} pk - The secp256k1 public key.
* @param {Bytes<32>} salt - The instance salt.
* @returns {Bytes<32>} The signer commitment.
*/
export pure circuit _calculateSignerId(
pk: Bytes<64>,
pk: Secp256k1Point,
salt: Bytes<32>
): Bytes<32> {
return persistentHash<SignerCommitmentInput>(SignerCommitmentInput {
pk: pk,
return keccak256<SignerCommitmentInput>(SignerCommitmentInput {
pkX: secp256k1PointX(pk) as Bytes<32>,
pkY: secp256k1PointY(pk) as Bytes<32>,
salt: salt,
domain: pad(32, "multisig:signer:")
});
}

/**
* @description Stub for ECDSA signature verification.
* Always returns true. MUST be replaced before any non-test deployment.
*/
circuit stubVerifySignature(
pubkey: Bytes<64>,
msgHash: Bytes<32>,
signature: Bytes<64>
): Boolean {
return true;
}

// ─── View ───────────────────────────────────────────────────────

export circuit getNonce(): Uint<64> {
Expand Down
Loading
Loading