From f2453b127902f8cb9f189e8696cea8ec8ab7e856 Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 24 Jul 2026 22:41:08 -0300 Subject: [PATCH 1/4] save tmp deps and resolutions --- contracts/package.json | 2 +- package.json | 3 ++- yarn.lock | 40 +++++++++++++++++++++++----------------- 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/contracts/package.json b/contracts/package.json index 0fa21b80..2a3ebf66 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -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", diff --git a/package.json b/package.json index 24440e78..4e8c82fb 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ "postcss": "8.5.10", "picomatch": "4.0.4", "ip-address": "10.1.1", - "@midnight-ntwrk/wallet-sdk-facade": "4.0.1" + "@midnight-ntwrk/wallet-sdk-facade": "4.0.1", + "@midnight-ntwrk/compact-runtime": "portal:../minokawa-compact/compact/runtime" }, "dependencies": { "@midnight-ntwrk/compact-runtime": "0.16.0" diff --git a/yarn.lock b/yarn.lock index ad7b2e3c..15ed596e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -373,16 +373,17 @@ __metadata: languageName: node linkType: hard -"@midnight-ntwrk/compact-runtime@npm:0.16.0": - version: 0.16.0 - resolution: "@midnight-ntwrk/compact-runtime@npm:0.16.0" +"@midnight-ntwrk/compact-runtime@portal:../minokawa-compact/compact/runtime::locator=openzeppelin-compact%40workspace%3A.": + version: 0.0.0-use.local + resolution: "@midnight-ntwrk/compact-runtime@portal:../minokawa-compact/compact/runtime::locator=openzeppelin-compact%40workspace%3A." dependencies: - "@midnight-ntwrk/onchain-runtime-v3": "npm:^3.0.0" + "@midnightntwrk/onchain-runtime-v4": "npm:^4.0.0-rc.3" + "@noble/curves": "npm:^2.2.0" + "@noble/hashes": "npm:^2.0.1" "@types/object-inspect": "npm:^1.8.1" object-inspect: "npm:^1.12.3" - checksum: 10/ef0c68d53bba6a04f336094c82c26b781082d7ce4ee09f0539009fb108776b36ea24b9a774292d9bbf9722b8a78d47254b5f80a613d4010a7f7d108514243023 languageName: node - linkType: hard + linkType: soft "@midnight-ntwrk/dapp-connector-api@npm:4.0.1": version: 4.0.1 @@ -528,7 +529,7 @@ __metadata: languageName: node linkType: hard -"@midnight-ntwrk/onchain-runtime-v3@npm:3.0.0, @midnight-ntwrk/onchain-runtime-v3@npm:^3.0.0": +"@midnight-ntwrk/onchain-runtime-v3@npm:3.0.0": version: 3.0.0 resolution: "@midnight-ntwrk/onchain-runtime-v3@npm:3.0.0" checksum: 10/873aeb9e631c3678373c62b5aef847de454de94427028fb3d3f28bfdc8b2c02a3c770bd79d9bfef183eb9db6fb8c23e6826636f2e512ffd6eacbcf7cc0651c5d @@ -801,6 +802,13 @@ __metadata: languageName: node linkType: hard +"@midnightntwrk/onchain-runtime-v4@npm:^4.0.0-rc.3": + version: 4.0.0-rc.3 + resolution: "@midnightntwrk/onchain-runtime-v4@npm:4.0.0-rc.3" + checksum: 10/394a4ff27b575b0e30bcb5ca05c2f89dc3bc7915103aa592c1d89bb32ff8589ac732d4d0bdafb4a99a9757e1a41ce21955f68148f783edb83b21988b24154078 + languageName: node + linkType: hard + "@msgpackr-extract/msgpackr-extract-darwin-arm64@npm:3.0.4": version: 3.0.4 resolution: "@msgpackr-extract/msgpackr-extract-darwin-arm64@npm:3.0.4" @@ -862,7 +870,7 @@ __metadata: languageName: node linkType: hard -"@noble/curves@npm:2.2.0": +"@noble/curves@npm:2.2.0, @noble/curves@npm:^2.2.0": version: 2.2.0 resolution: "@noble/curves@npm:2.2.0" dependencies: @@ -887,7 +895,7 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:2.2.0, @noble/hashes@npm:^2.2.0": +"@noble/hashes@npm:2.2.0, @noble/hashes@npm:^2.0.1, @noble/hashes@npm:^2.2.0": version: 2.2.0 resolution: "@noble/hashes@npm:2.2.0" checksum: 10/b1b78bedc2a01394be047429f3d888905015fe8a09f1b7e43e0b5736b54133df62f73dcc73ede43af38e96e86156afb45b86973fdeaa95d9f0880333c3fc0907 @@ -958,7 +966,7 @@ __metadata: "@midnight-ntwrk/midnight-js-types": "npm:4.1.1" "@midnight-ntwrk/testkit-js": "npm:4.1.1" "@openzeppelin/compact-cli": "npm:^0.0.2" - "@openzeppelin/compact-simulator": "npm:^0.2.0" + "@openzeppelin/compact-simulator": "portal:../../compact-tools-pub/packages/simulator" "@tsconfig/node24": "npm:^24.0.4" "@types/node": "npm:26.1.1" "@vitest/coverage-v8": "npm:^4.1.10" @@ -969,12 +977,11 @@ __metadata: languageName: unknown linkType: soft -"@openzeppelin/compact-simulator@npm:^0.2.0": - version: 0.2.0 - resolution: "@openzeppelin/compact-simulator@npm:0.2.0" +"@openzeppelin/compact-simulator@portal:../../compact-tools-pub/packages/simulator::locator=%40openzeppelin%2Fcompact-contracts%40workspace%3Acontracts": + version: 0.0.0-use.local + resolution: "@openzeppelin/compact-simulator@portal:../../compact-tools-pub/packages/simulator::locator=%40openzeppelin%2Fcompact-contracts%40workspace%3Acontracts" dependencies: - "@midnight-ntwrk/compact-runtime": "npm:0.16.0" - "@midnight-ntwrk/ledger-v8": "npm:8.1.0" + "@midnight-ntwrk/compact-runtime": "portal:../../../minokawa-compact/compact/runtime" peerDependencies: "@midnight-ntwrk/midnight-js-contracts": ^4.1.0 "@midnight-ntwrk/midnight-js-types": ^4.1.0 @@ -983,9 +990,8 @@ __metadata: optional: true "@midnight-ntwrk/midnight-js-types": optional: true - checksum: 10/294e53a3eaade37ae679be8aaff764239d8cb645eae0e69ceb56587b36614ed67b9a38ad641633d91a6ee65d96920475ccedec40c1f1dd734d26a86b0382ce90 languageName: node - linkType: hard + linkType: soft "@oxc-project/types@npm:=0.133.0": version: 0.133.0 From fb227ea63d6f342c67fcb6d34223c726f2208ae5 Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 24 Jul 2026 22:41:34 -0300 Subject: [PATCH 2/4] integrate ecdsa sig ver --- .../presets/ShieldedMultiSigV2.compact | 63 ++- .../presets/ShieldedMultiSigV3.compact | 94 ++-- contracts/src/multisig/test/EcdsaTestUtils.ts | 185 ++++++++ .../multisig/test/ShieldedMultiSigV2.test.ts | 148 ++++-- .../multisig/test/ShieldedMultiSigV3.test.ts | 431 ++++++++++-------- .../simulators/ShieldedMultiSigV2Simulator.ts | 8 +- .../simulators/ShieldedMultiSigV3Simulator.ts | 14 +- contracts/test-utils/fixtures/address.ts | 4 +- 8 files changed, 612 insertions(+), 335 deletions(-) create mode 100644 contracts/src/multisig/test/EcdsaTestUtils.ts diff --git a/contracts/src/multisig/presets/ShieldedMultiSigV2.compact b/contracts/src/multisig/presets/ShieldedMultiSigV2.compact index 430bf59e..350f4641 100644 --- a/contracts/src/multisig/presets/ShieldedMultiSigV2.compact +++ b/contracts/src/multisig/presets/ShieldedMultiSigV2.compact @@ -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> } @@ -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 @@ -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 real 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 @@ -144,8 +148,8 @@ 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. */ @@ -153,8 +157,8 @@ 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; @@ -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); @@ -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>, @@ -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 { - 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> { diff --git a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact index 23d6ba81..5dac0fa2 100644 --- a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact +++ b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact @@ -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 real secp256k1 ECDSA + * verification (`secp256k1EcdsaVerify`), and all operation/commitment hashing + * uses `keccak256` to match the custodian's Ethereum-style HSM signing format. */ import CompactStandardLibrary; @@ -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> } @@ -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 @@ -136,9 +140,9 @@ 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` to match the + * custodian's Ethereum-style HSM signing format, and each signature is + * checked with `secp256k1EcdsaVerify`. * * Requirements: * @@ -149,23 +153,23 @@ constructor( * * @param {Uint<64>} amount - The token amount to mint. * @param {Either} 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, - 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(recipient); - const recipientHash = persistentHash>(canonRecipient); + const recipientHash = keccak256>(canonRecipient); - const msgHash = persistentHash>>([ + const msgHash = keccak256>>([ pad(32, "multisig:mint:"), kernel.self().bytes, recipientHash, @@ -211,9 +215,9 @@ 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` to match the + * custodian's Ethereum-style HSM signing format, and each signature is + * checked with `secp256k1EcdsaVerify`. * * Requirements: * @@ -226,19 +230,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>>([ + const msgHash = keccak256>>([ pad(32, "multisig:burn:"), kernel.self().bytes, opNonce as Bytes<32>, @@ -277,14 +281,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); @@ -293,8 +297,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>, @@ -304,43 +307,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 { - pk: pk, + return keccak256(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> { diff --git a/contracts/src/multisig/test/EcdsaTestUtils.ts b/contracts/src/multisig/test/EcdsaTestUtils.ts new file mode 100644 index 00000000..80c1a098 --- /dev/null +++ b/contracts/src/multisig/test/EcdsaTestUtils.ts @@ -0,0 +1,185 @@ +/** + * Test helpers for the ECDSA-backed multisig presets. + * + * Two responsibilities: + * 1. Produce real secp256k1 key pairs and ECDSA signatures (via `@noble/curves`) + * in the shape the compiled circuits expect, a `Secp256k1Point` public key + * (`{ x, y, identity }`) and a `{ r, s }` signature of scalar field elements. + * 2. Reconstruct, byte-for-byte, the message digest each circuit hashes and + * verifies. This mirrors what a real operator/HSM must do off-chain: it + * reuses the runtime's own `keccak256` / `persistentHash` / `convertBigintToBytes` + * primitives with `CompactType`s built identically to the generated artifact, + * so the digest is guaranteed to match the in-circuit computation. + */ +import { secp256k1 } from '@noble/curves/secp256k1.js'; +import { + CompactTypeBoolean, + CompactTypeBytes, + CompactTypeVector, + type CompactType, + convertBigintToBytes, + keccak256, + persistentHash, + type Secp256k1Point, +} from '@midnight-ntwrk/compact-runtime'; + +// ─── Keys & signatures ────────────────────────────────────────── + +/** An ECDSA signature as the circuits consume it: two secp256k1 scalars. */ +export interface EcdsaSignature { + r: bigint; + s: bigint; +} + +/** A secp256k1 signer: its secret key plus the public key as a circuit point. */ +export interface Signer { + secretKey: Uint8Array; + publicKey: Secp256k1Point; +} + +const bytesToBigIntBE = (bytes: Uint8Array): bigint => { + let acc = 0n; + for (const b of bytes) acc = (acc << 8n) | BigInt(b); + return acc; +}; + +/** Derives a signer from a 32-byte secret key (random if omitted). */ +export function makeSigner(secretKey?: Uint8Array): Signer { + const sk = secretKey ?? secp256k1.utils.randomSecretKey(); + const uncompressed = secp256k1.getPublicKey(sk, false); // 0x04 || X(32) || Y(32) + return { + secretKey: sk, + publicKey: { + x: bytesToBigIntBE(uncompressed.slice(1, 33)), + y: bytesToBigIntBE(uncompressed.slice(33, 65)), + identity: false, + }, + }; +} + +/** Deterministic signer from an ASCII label (handy for stable test fixtures). */ +export function signerFromLabel(label: string): Signer { + const sk = new Uint8Array(32); + const ascii = new TextEncoder().encode(label); + sk.set(ascii.slice(0, 32)); + sk[31] ||= 1; // avoid the zero scalar + return makeSigner(sk); +} + +/** + * Signs a 32-byte digest, returning `{ r, s }`. The digest is treated as the + * pre-hashed message, exactly as `secp256k1EcdsaVerify` interprets `msgHash`. + */ +export function sign(signer: Signer, digest: Uint8Array): EcdsaSignature { + // `@noble/curves` v2 returns the signature as a compact 64-byte (r‖s) array; + // recover the scalar components the circuit expects via `Signature.fromBytes`. + const compact = secp256k1.sign(digest, signer.secretKey, { prehash: false }); + const parsed = secp256k1.Signature.fromBytes(compact); + return { r: parsed.r, s: parsed.s }; +} + +// ─── Digest reconstruction ────────────────────────────────────── + +const B32 = new CompactTypeBytes(32); +const vecType = (n: number): CompactType => + new CompactTypeVector(n, B32) as unknown as CompactType; + +/** `pad(32, s)`: ASCII bytes of `s`, right-padded with zeros to 32 bytes. */ +export function domainBytes(s: string): Uint8Array { + const out = new Uint8Array(32); + out.set(new TextEncoder().encode(s)); + return out; +} + +const u256 = (value: bigint): Uint8Array => + convertBigintToBytes(32, value, 'EcdsaTestUtils'); + +/** `keccak256>>(items)`. */ +const keccakVec = (items: Uint8Array[]): Uint8Array => + keccak256(vecType(items.length), items); + +/** `persistentHash>>(items)`. */ +const persistentVec = (items: Uint8Array[]): Uint8Array => + persistentHash(vecType(items.length), items); + +/** An `Either` as the artifact encodes it. */ +export interface EitherRecipient { + is_left: boolean; + left: { bytes: Uint8Array }; + right: { bytes: Uint8Array }; +} + +// Mirrors the generated `_Either_0` descriptor: bool ‖ left.bytes ‖ right.bytes. +const EitherType: CompactType = { + alignment: () => + CompactTypeBoolean.alignment() + .concat(B32.alignment()) + .concat(B32.alignment()), + fromValue: (value) => ({ + is_left: CompactTypeBoolean.fromValue(value), + left: { bytes: B32.fromValue(value) }, + right: { bytes: B32.fromValue(value) }, + }), + toValue: (value) => + CompactTypeBoolean.toValue(value.is_left) + .concat(B32.toValue(value.left.bytes)) + .concat(B32.toValue(value.right.bytes)), +}; + +// Matches the contract's `Utils_canonicalize`: zero out the unused side. +const canonicalize = (r: EitherRecipient): EitherRecipient => + r.is_left + ? { is_left: true, left: r.left, right: { bytes: new Uint8Array(32) } } + : { is_left: false, left: { bytes: new Uint8Array(32) }, right: r.right }; + +/** `keccak256>(canonicalize(recipient))` — the mint's recipientHash. */ +export function recipientHashKeccak(recipient: EitherRecipient): Uint8Array { + return keccak256(EitherType, canonicalize(recipient)); +} + +// ─── Per-preset message hashes ────────────────────────────────── + +/** ShieldedMultiSigV3 `mint` digest. `contractAddress` is `kernel.self().bytes`. */ +export function mintMsgHash(params: { + contractAddress: Uint8Array; + recipient: EitherRecipient; + opNonce: bigint; + amount: bigint; +}): Uint8Array { + return keccakVec([ + domainBytes('multisig:mint:'), + params.contractAddress, + recipientHashKeccak(params.recipient), + u256(params.opNonce), + u256(params.amount), + ]); +} + +/** ShieldedMultiSigV3 `burn` digest. */ +export function burnMsgHash(params: { + contractAddress: Uint8Array; + opNonce: bigint; + amount: bigint; +}): Uint8Array { + return keccakVec([ + domainBytes('multisig:burn:'), + params.contractAddress, + u256(params.opNonce), + u256(params.amount), + ]); +} + +/** ShieldedMultiSigV2 `execute` digest (persistentHash, no domain prefix). */ +export function executeMsgHash(params: { + nonce: bigint; + toAddress: Uint8Array; + coinColor: Uint8Array; + amount: bigint; +}): Uint8Array { + return persistentVec([ + u256(params.nonce), + params.toAddress, + params.coinColor, + u256(params.amount), + ]); +} diff --git a/contracts/src/multisig/test/ShieldedMultiSigV2.test.ts b/contracts/src/multisig/test/ShieldedMultiSigV2.test.ts index 680b12ec..6df0bc2a 100644 --- a/contracts/src/multisig/test/ShieldedMultiSigV2.test.ts +++ b/contracts/src/multisig/test/ShieldedMultiSigV2.test.ts @@ -1,8 +1,15 @@ +import { isLiveBackend } from '@openzeppelin/compact-simulator'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { GENESIS_NATIVE_SHIELDED_TOKEN_COLORS, encodeShieldedCoinInfo as makeCoin, } from '#test-utils/fixtures/nativeShieldedToken.js'; +import { + executeMsgHash, + type Signer, + sign, + signerFromLabel, +} from './EcdsaTestUtils.js'; import { ShieldedMultiSigV2Simulator } from './simulators/ShieldedMultiSigV2Simulator.js'; const RecipientKind = { ShieldedUser: 0, UnshieldedUser: 1, Contract: 2 }; @@ -13,31 +20,27 @@ const INSTANCE_SALT = new Uint8Array(32).fill(0xaa); const COLOR = GENESIS_NATIVE_SHIELDED_TOKEN_COLORS.nativeShieldedToken1; const AMOUNT = 1000n; -const PK1 = new Uint8Array(64).fill(0x11); -const PK2 = new Uint8Array(64).fill(0x22); -const PK3 = new Uint8Array(64).fill(0x33); -const NON_SIGNER_PK = new Uint8Array(64).fill(0x99); +// Real secp256k1 signers (deterministic from labels). Authorization requires a +// real ECDSA signature over the operation's persistentHash message digest. +const S1 = signerFromLabel('v2-signer-1'); +const S2 = signerFromLabel('v2-signer-2'); +const S3 = signerFromLabel('v2-signer-3'); +const OUTSIDER = signerFromLabel('v2-outsider'); const COMMITMENT1 = ShieldedMultiSigV2Simulator.calculateSignerId( - PK1, + S1.publicKey, INSTANCE_SALT, ); const COMMITMENT2 = ShieldedMultiSigV2Simulator.calculateSignerId( - PK2, + S2.publicKey, INSTANCE_SALT, ); const COMMITMENT3 = ShieldedMultiSigV2Simulator.calculateSignerId( - PK3, + S3.publicKey, INSTANCE_SALT, ); const SIGNER_COMMITMENTS = [COMMITMENT1, COMMITMENT2, COMMITMENT3]; -// ECDSA verification is stubbed in the contract (`stubVerifySignature` returns -// true), so any 64-byte value passes. Authorization is enforced only by -// signer-commitment membership and duplicate detection — both caller-agnostic, -// so this spec runs unchanged on live (no `ownPublicKey`-based caller identity). -const DUMMY_SIG = new Uint8Array(64).fill(0xff); - function makeRecipient(address: Uint8Array): { kind: number; address: Uint8Array; @@ -66,6 +69,22 @@ function makeQualifiedCoin( let multisig: ShieldedMultiSigV2Simulator; +// The execute digest the contract computes: persistentHash([nonce, to.address, +// coin.color, amount]). +async function executeDigest( + m: ShieldedMultiSigV2Simulator, + to: { address: Uint8Array }, + coin: { color: Uint8Array }, + amount: bigint, +): Promise { + return executeMsgHash({ + nonce: await m.getNonce(), + toAddress: to.address, + coinColor: coin.color, + amount, + }); +} + // A fresh 2-of-3 stateless multisig. Mutating groups build one per test // (`beforeEach`); the read-only `view` group shares one deploy (`beforeAll`). const freshMultisig = () => @@ -74,11 +93,7 @@ const freshMultisig = () => describe('ShieldedMultiSigV2', () => { describe('constructor', () => { it('should initialize with 2-of-3 threshold', async () => { - multisig = await ShieldedMultiSigV2Simulator.create( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 2n, - ); + multisig = await freshMultisig(); expect(await multisig.getSignerCount()).toEqual(3n); expect(await multisig.getThreshold()).toEqual(2n); }); @@ -94,45 +109,29 @@ describe('ShieldedMultiSigV2', () => { it('should fail with zero threshold', async () => { await expect( - ShieldedMultiSigV2Simulator.create( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 0n, - ), + ShieldedMultiSigV2Simulator.create(INSTANCE_SALT, SIGNER_COMMITMENTS, 0n), ).rejects.toThrow('SignerManager: threshold must be > 0'); }); it('should fail with threshold greater than 2', async () => { await expect( - ShieldedMultiSigV2Simulator.create( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 3n, - ), + ShieldedMultiSigV2Simulator.create(INSTANCE_SALT, SIGNER_COMMITMENTS, 3n), ).rejects.toThrow( 'ShieldedMultiSigV2: threshold cannot exceed 2 (execute verifies at most 2 signatures)', ); }); it('should register all signer commitments', async () => { - multisig = await ShieldedMultiSigV2Simulator.create( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 2n, - ); + multisig = await freshMultisig(); for (const commitment of SIGNER_COMMITMENTS) { expect(await multisig.isSigner(commitment)).toEqual(true); } }); it('should reject a non-signer commitment', async () => { - multisig = await ShieldedMultiSigV2Simulator.create( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 2n, - ); + multisig = await freshMultisig(); const unknown = ShieldedMultiSigV2Simulator.calculateSignerId( - NON_SIGNER_PK, + OUTSIDER.publicKey, INSTANCE_SALT, ); expect(await multisig.isSigner(unknown)).toEqual(false); @@ -173,27 +172,92 @@ describe('ShieldedMultiSigV2', () => { multisig = await freshMultisig(); }); + // A real send spends a deposited coin; dry-only until the live harness can + // fund and track it. + describe.skipIf(isLiveBackend())('happy path (dry only)', () => { + async function execute( + to: { kind: number; address: Uint8Array }, + amount: bigint, + coin: { + nonce: Uint8Array; + color: Uint8Array; + value: bigint; + mt_index: bigint; + }, + signers: Signer[], + ) { + const digest = await executeDigest(multisig, to, coin, amount); + return multisig.execute( + to, + amount, + coin, + signers.map((s) => s.publicKey), + signers.map((s) => sign(s, digest)), + ); + } + + it('should execute a send with signers 0 and 1', async () => { + await multisig.deposit(makeCoin(COLOR, AMOUNT)); + const to = makeRecipient(new Uint8Array(32).fill(7)); + const coin = makeQualifiedCoin(COLOR, AMOUNT, 0n); + await execute(to, 100n, coin, [S1, S2]); + expect(await multisig.getNonce()).toEqual(1n); + }); + + it('should execute with signers 1 and 2', async () => { + await multisig.deposit(makeCoin(COLOR, AMOUNT)); + const to = makeRecipient(new Uint8Array(32).fill(7)); + const coin = makeQualifiedCoin(COLOR, AMOUNT, 0n); + await execute(to, 100n, coin, [S2, S3]); + expect(await multisig.getNonce()).toEqual(1n); + }); + }); + it('should reject duplicate signer', async () => { const to = makeRecipient(new Uint8Array(32).fill(7)); const coin = makeQualifiedCoin(COLOR, AMOUNT, 0n); + const digest = await executeDigest(multisig, to, coin, 100n); await expect( - multisig.execute(to, 100n, coin, [PK1, PK1], [DUMMY_SIG, DUMMY_SIG]), + multisig.execute( + to, + 100n, + coin, + [S1.publicKey, S1.publicKey], + [sign(S1, digest), sign(S1, digest)], + ), ).rejects.toThrow('Multisig: duplicate signer'); }); it('should reject a non-signer pubkey', async () => { const to = makeRecipient(new Uint8Array(32).fill(7)); const coin = makeQualifiedCoin(COLOR, AMOUNT, 0n); + const digest = await executeDigest(multisig, to, coin, 100n); await expect( multisig.execute( to, 100n, coin, - [PK1, NON_SIGNER_PK], - [DUMMY_SIG, DUMMY_SIG], + [S1.publicKey, OUTSIDER.publicKey], + [sign(S1, digest), sign(OUTSIDER, digest)], ), ).rejects.toThrow('SignerManager: not a signer'); }); + + it('should reject an invalid signature', async () => { + const to = makeRecipient(new Uint8Array(32).fill(7)); + const coin = makeQualifiedCoin(COLOR, AMOUNT, 0n); + const digest = await executeDigest(multisig, to, coin, 100n); + // S2's pubkey is registered, but the signature is made by S3. + await expect( + multisig.execute( + to, + 100n, + coin, + [S1.publicKey, S2.publicKey], + [sign(S1, digest), sign(S3, digest)], + ), + ).rejects.toThrow('Multisig: invalid signature'); + }); }); }); }); diff --git a/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts b/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts index fded63c3..1c592096 100644 --- a/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts +++ b/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts @@ -2,6 +2,14 @@ import { isLiveBackend } from '@openzeppelin/compact-simulator'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; import * as utils from '#test-utils/fixtures/address.js'; import { shieldedTestRecipient } from '#test-utils/fixtures/shieldedKey.js'; +import { + burnMsgHash, + type EitherRecipient, + mintMsgHash, + type Signer, + sign, + signerFromLabel, +} from './EcdsaTestUtils.js'; import { calculateSignerId, ShieldedMultiSigV3Simulator, @@ -14,22 +22,20 @@ const INIT_COIN_NONCE = new Uint8Array(32).fill(0xbb); const TOKEN_DOMAIN = new Uint8Array(32); Buffer.from('smt:token:').copy(TOKEN_DOMAIN); -// Signer identity is a commitment (`calculateSignerId(pk, salt)`) passed to -// `mint`/`burn` explicitly, and ECDSA verification is stubbed (`DUMMY_SIG` -// passes), so authorization is caller-agnostic — this spec's signer logic runs -// unchanged on live (no `ownPublicKey`-based identity). -const PK1 = new Uint8Array(64).fill(0x11); -const PK2 = new Uint8Array(64).fill(0x22); -const PK3 = new Uint8Array(64).fill(0x33); -const NON_SIGNER_PK = new Uint8Array(64).fill(0x99); - -const COMMITMENT1 = calculateSignerId(PK1, INSTANCE_SALT); -const COMMITMENT2 = calculateSignerId(PK2, INSTANCE_SALT); -const COMMITMENT3 = calculateSignerId(PK3, INSTANCE_SALT); +// Real secp256k1 signers. A signer's on-chain identity is the commitment +// `calculateSignerId(pk, salt)`; authorization now requires a real ECDSA +// signature over the operation's keccak256 message hash. Deterministic keys +// (derived from labels) keep the fixtures stable across runs. +const S1 = signerFromLabel('multisig-signer-1'); +const S2 = signerFromLabel('multisig-signer-2'); +const S3 = signerFromLabel('multisig-signer-3'); +const OUTSIDER = signerFromLabel('multisig-outsider'); + +const COMMITMENT1 = calculateSignerId(S1.publicKey, INSTANCE_SALT); +const COMMITMENT2 = calculateSignerId(S2.publicKey, INSTANCE_SALT); +const COMMITMENT3 = calculateSignerId(S3.publicKey, INSTANCE_SALT); const SIGNER_COMMITMENTS = [COMMITMENT1, COMMITMENT2, COMMITMENT3]; -const DUMMY_SIG = new Uint8Array(64).fill(0xff); - // A contract recipient for `mint`. Dry-only: minting to a non-participating // contract publishes an output no one claims, which a live node rejects (the // same unclaimed-output limit that blocks atomic contract-recipient sends). @@ -41,6 +47,53 @@ const CONTRACT_RECIPIENT = utils.createEitherTestContractAddress('TARGET'); // user. let USER_RECIPIENT: ReturnType; +// ─── Signing helpers ────────────────────────────────────────────── + +const addrBytes = (m: ShieldedMultiSigV3Simulator): Uint8Array => + Uint8Array.from(Buffer.from(m.contractAddress, 'hex')); + +/** The mint digest the contract will compute for these params at its current nonce. */ +async function mintDigest( + m: ShieldedMultiSigV3Simulator, + recipient: EitherRecipient, + amount: bigint, +): Promise { + return mintMsgHash({ + contractAddress: addrBytes(m), + recipient, + opNonce: await m.getNonce(), + amount, + }); +} + +/** The burn digest the contract will compute for these params at its current nonce. */ +async function burnDigest( + m: ShieldedMultiSigV3Simulator, + amount: bigint, +): Promise { + return burnMsgHash({ + contractAddress: addrBytes(m), + opNonce: await m.getNonce(), + amount, + }); +} + +/** Mint, signing the correct digest with each of `signers`. */ +async function mint( + m: ShieldedMultiSigV3Simulator, + amount: bigint, + recipient: EitherRecipient, + signers: Signer[], +): Promise { + const digest = await mintDigest(m, recipient, amount); + await m.mint( + amount, + recipient, + signers.map((s) => s.publicKey), + signers.map((s) => sign(s, digest)), + ); +} + function makeQualifiedCoin( color: Uint8Array, value: bigint, @@ -76,37 +129,22 @@ const freshMultisig = () => describe('ShieldedMultiSigV3', () => { describe('constructor', () => { it('should initialize', async () => { - multisig = await ShieldedMultiSigV3Simulator.create( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - SIGNER_COMMITMENTS, - ); + multisig = await freshMultisig(); expect(await multisig.getSignerCount()).toEqual(3n); expect(await multisig.getThreshold()).toEqual(2n); }); it('should register all signer commitments', async () => { - multisig = await ShieldedMultiSigV3Simulator.create( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - SIGNER_COMMITMENTS, - ); + multisig = await freshMultisig(); for (const commitment of SIGNER_COMMITMENTS) { expect(await multisig.isSigner(commitment)).toEqual(true); } }); it('should reject a non-signer commitment', async () => { - multisig = await ShieldedMultiSigV3Simulator.create( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - SIGNER_COMMITMENTS, - ); + multisig = await freshMultisig(); const unknown = await multisig._calculateSignerId( - NON_SIGNER_PK, + OUTSIDER.publicKey, INSTANCE_SALT, ); expect(await multisig.isSigner(unknown)).toEqual(false); @@ -124,21 +162,12 @@ describe('ShieldedMultiSigV3', () => { }); it('should store token domain', async () => { - multisig = await ShieldedMultiSigV3Simulator.create( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - SIGNER_COMMITMENTS, - ); + multisig = await freshMultisig(); expect(await multisig.getTokenDomain()).toEqual(TOKEN_DOMAIN); }); }); describe('when initialized', () => { - // USER_RECIPIENT is stable (deployer key on live, synthetic on dry), so - // resolve it once after the first deploy. The read-only `view` and - // `_calculateSignerId` groups run first and reuse this shared deploy; - // mutating groups below build their own fresh instance per test. beforeAll(async () => { multisig = await freshMultisig(); USER_RECIPIENT = shieldedTestRecipient(); @@ -170,34 +199,34 @@ describe('ShieldedMultiSigV3', () => { describe('_calculateSignerId', () => { it('should produce deterministic commitments', async () => { - const c1 = await multisig._calculateSignerId(PK1, INSTANCE_SALT); - const c2 = await multisig._calculateSignerId(PK1, INSTANCE_SALT); + const c1 = await multisig._calculateSignerId(S1.publicKey, INSTANCE_SALT); + const c2 = await multisig._calculateSignerId(S1.publicKey, INSTANCE_SALT); expect(c1).toEqual(c2); }); it('should produce different commitments for different keys', async () => { - const c1 = await multisig._calculateSignerId(PK1, INSTANCE_SALT); - const c2 = await multisig._calculateSignerId(PK2, INSTANCE_SALT); + const c1 = await multisig._calculateSignerId(S1.publicKey, INSTANCE_SALT); + const c2 = await multisig._calculateSignerId(S2.publicKey, INSTANCE_SALT); expect(c1).not.toEqual(c2); }); it('should produce different commitments for different salts', async () => { const salt2 = new Uint8Array(32).fill(0xcc); - const c1 = await multisig._calculateSignerId(PK1, INSTANCE_SALT); - const c2 = await multisig._calculateSignerId(PK1, salt2); + const c1 = await multisig._calculateSignerId(S1.publicKey, INSTANCE_SALT); + const c2 = await multisig._calculateSignerId(S1.publicKey, salt2); expect(c1).not.toEqual(c2); }); it('should match registered commitments', async () => { - expect(await multisig._calculateSignerId(PK1, INSTANCE_SALT)).toEqual( - COMMITMENT1, - ); - expect(await multisig._calculateSignerId(PK2, INSTANCE_SALT)).toEqual( - COMMITMENT2, - ); - expect(await multisig._calculateSignerId(PK3, INSTANCE_SALT)).toEqual( - COMMITMENT3, - ); + expect( + await multisig._calculateSignerId(S1.publicKey, INSTANCE_SALT), + ).toEqual(COMMITMENT1); + expect( + await multisig._calculateSignerId(S2.publicKey, INSTANCE_SALT), + ).toEqual(COMMITMENT2); + expect( + await multisig._calculateSignerId(S3.publicKey, INSTANCE_SALT), + ).toEqual(COMMITMENT3); }); }); @@ -207,30 +236,15 @@ describe('ShieldedMultiSigV3', () => { }); it('should mint to a user recipient with signers 0 and 1', async () => { - await multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); + await mint(multisig, 100n, USER_RECIPIENT, [S1, S2]); }); it('should mint to a user recipient with signers 0 and 2', async () => { - await multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK3], - [DUMMY_SIG, DUMMY_SIG], - ); + await mint(multisig, 100n, USER_RECIPIENT, [S1, S3]); }); it('should mint to a user recipient with signers 1 and 2', async () => { - await multisig.mint( - 100n, - USER_RECIPIENT, - [PK2, PK3], - [DUMMY_SIG, DUMMY_SIG], - ); + await mint(multisig, 100n, USER_RECIPIENT, [S2, S3]); }); // Live: a mint to a non-participating contract leaves an unclaimed output @@ -238,104 +252,99 @@ describe('ShieldedMultiSigV3', () => { it.skipIf(isLiveBackend())( 'should mint to a contract recipient', async () => { - await multisig.mint( - 100n, - CONTRACT_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); + await mint(multisig, 100n, CONTRACT_RECIPIENT, [S1, S2]); }, ); it('should reject duplicate signer', async () => { + const digest = await mintDigest(multisig, USER_RECIPIENT, 100n); await expect( multisig.mint( 100n, USER_RECIPIENT, - [PK1, PK1], - [DUMMY_SIG, DUMMY_SIG], + [S1.publicKey, S1.publicKey], + [sign(S1, digest), sign(S1, digest)], ), ).rejects.toThrow('Multisig: duplicate signer'); }); it('should reject a non-signer pubkey', async () => { + const digest = await mintDigest(multisig, USER_RECIPIENT, 100n); await expect( multisig.mint( 100n, USER_RECIPIENT, - [PK1, NON_SIGNER_PK], - [DUMMY_SIG, DUMMY_SIG], + [S1.publicKey, OUTSIDER.publicKey], + [sign(S1, digest), sign(OUTSIDER, digest)], ), ).rejects.toThrow('Signer: not a signer'); }); + it('should reject a signature that does not match the message', async () => { + // S2 signs a digest for a different amount; its pubkey is a registered + // signer, so the failure is the ECDSA check, not membership. + const digest = await mintDigest(multisig, USER_RECIPIENT, 100n); + const wrongDigest = mintMsgHash({ + contractAddress: addrBytes(multisig), + recipient: USER_RECIPIENT, + opNonce: await multisig.getNonce(), + amount: 999n, + }); + await expect( + multisig.mint( + 100n, + USER_RECIPIENT, + [S1.publicKey, S2.publicKey], + [sign(S1, digest), sign(S2, wrongDigest)], + ), + ).rejects.toThrow('Multisig: invalid signature'); + }); + + it('should reject a signature from the wrong key', async () => { + // Present S2's registered pubkey but a signature made by S3. + const digest = await mintDigest(multisig, USER_RECIPIENT, 100n); + await expect( + multisig.mint( + 100n, + USER_RECIPIENT, + [S1.publicKey, S2.publicKey], + [sign(S1, digest), sign(S3, digest)], + ), + ).rejects.toThrow('Multisig: invalid signature'); + }); + it('should increment nonce after mint', async () => { expect(await multisig.getNonce()).toEqual(0n); - await multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); + await mint(multisig, 100n, USER_RECIPIENT, [S1, S2]); expect(await multisig.getNonce()).toEqual(1n); }); it('should increment nonce on each mint', async () => { - await multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - await multisig.mint( - 200n, - USER_RECIPIENT, - [PK1, PK3], - [DUMMY_SIG, DUMMY_SIG], - ); - await multisig.mint( - 300n, - USER_RECIPIENT, - [PK2, PK3], - [DUMMY_SIG, DUMMY_SIG], - ); + await mint(multisig, 100n, USER_RECIPIENT, [S1, S2]); + await mint(multisig, 200n, USER_RECIPIENT, [S1, S3]); + await mint(multisig, 300n, USER_RECIPIENT, [S2, S3]); expect(await multisig.getNonce()).toEqual(3n); }); it('should accept zero amount', async () => { - await multisig.mint( - 0n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); + await mint(multisig, 0n, USER_RECIPIENT, [S1, S2]); }); - it('should prevent replay by incrementing nonce', async () => { - await multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - // Second mint with same params succeeds because nonce is different - // (stub ver doesn't actually check signatures) - await multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - expect(await multisig.getNonce()).toEqual(2n); + it('should prevent replay by binding the signature to the nonce', async () => { + // Signatures valid for nonce 0 must not authorize a second mint (nonce 1). + const digest = await mintDigest(multisig, USER_RECIPIENT, 100n); + const pubkeys = [S1.publicKey, S2.publicKey]; + const sigs = [sign(S1, digest), sign(S2, digest)]; + await multisig.mint(100n, USER_RECIPIENT, pubkeys, sigs); + expect(await multisig.getNonce()).toEqual(1n); + // Replaying the exact same signatures now fails: the digest for nonce 1 + // differs, so the ECDSA check rejects them. + await expect( + multisig.mint(100n, USER_RECIPIENT, pubkeys, sigs), + ).rejects.toThrow('Multisig: invalid signature'); }); }); - // A successful burn spends a real coin of the contract's own token. Its - // nonce is derived inside the mint circuit, so the spec cannot reconstruct - // it to recover the coin's `mt_index` on live (that is the wallet SDK's - // ciphertext-discovery job, out of scope for the coin tracker). The - // rejection paths below throw before the receive/spend, so they run on both - // backends; the success paths are dry-only. describe('burn', () => { beforeEach(async () => { multisig = await freshMultisig(); @@ -344,99 +353,141 @@ describe('ShieldedMultiSigV3', () => { // Happy-path burns execute a real spend, so they are dry-only until the // live harness can fund and track the burned coin. describe.skipIf(isLiveBackend())('happy path (dry only)', () => { + async function burn( + amount: bigint, + coinValue: bigint, + signers: Signer[], + ): Promise { + const coin = makeQualifiedCoin( + await multisig.getTokenType(), + coinValue, + ); + const digest = await burnDigest(multisig, amount); + await multisig.burn( + coin, + amount, + signers.map((s) => s.publicKey), + signers.map((s) => sign(s, digest)), + ); + } + it('should burn with valid coin and signers 0 and 1', async () => { - const coin = makeQualifiedCoin(await multisig.getTokenType(), 100n); - await multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + await burn(100n, 100n, [S1, S2]); }); it('should burn with signers 0 and 2', async () => { - const coin = makeQualifiedCoin(await multisig.getTokenType(), 100n); - await multisig.burn(coin, 100n, [PK1, PK3], [DUMMY_SIG, DUMMY_SIG]); + await burn(100n, 100n, [S1, S3]); }); it('should burn with signers 1 and 2', async () => { - const coin = makeQualifiedCoin(await multisig.getTokenType(), 100n); - await multisig.burn(coin, 100n, [PK2, PK3], [DUMMY_SIG, DUMMY_SIG]); + await burn(100n, 100n, [S2, S3]); }); it('should burn partial amount', async () => { - const coin = makeQualifiedCoin(await multisig.getTokenType(), 100n); - await multisig.burn(coin, 50n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + await burn(50n, 100n, [S1, S2]); }); it('should handle zero burn amount', async () => { - const coin = makeQualifiedCoin(await multisig.getTokenType(), 100n); - await multisig.burn(coin, 0n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + await burn(0n, 100n, [S1, S2]); }); it('should share nonce across mint and burn', async () => { - await multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); + await mint(multisig, 100n, USER_RECIPIENT, [S1, S2]); expect(await multisig.getNonce()).toEqual(1n); - - const coin = makeQualifiedCoin(await multisig.getTokenType(), 100n); - await multisig.burn(coin, 50n, [PK1, PK3], [DUMMY_SIG, DUMMY_SIG]); + await burn(50n, 100n, [S1, S3]); expect(await multisig.getNonce()).toEqual(2n); }); }); it('should reject duplicate signer', async () => { const coin = makeQualifiedCoin(await multisig.getTokenType(), 100n); + const digest = await burnDigest(multisig, 100n); await expect( - multisig.burn(coin, 100n, [PK1, PK1], [DUMMY_SIG, DUMMY_SIG]), + multisig.burn( + coin, + 100n, + [S1.publicKey, S1.publicKey], + [sign(S1, digest), sign(S1, digest)], + ), ).rejects.toThrow('Multisig: duplicate signer'); }); it('should reject a non-signer pubkey', async () => { const coin = makeQualifiedCoin(await multisig.getTokenType(), 100n); + const digest = await burnDigest(multisig, 100n); await expect( multisig.burn( coin, 100n, - [PK1, NON_SIGNER_PK], - [DUMMY_SIG, DUMMY_SIG], + [S1.publicKey, OUTSIDER.publicKey], + [sign(S1, digest), sign(OUTSIDER, digest)], ), ).rejects.toThrow('Signer: not a signer'); }); + it('should reject an invalid signature', async () => { + const coin = makeQualifiedCoin(await multisig.getTokenType(), 100n); + const digest = await burnDigest(multisig, 100n); + await expect( + multisig.burn( + coin, + 100n, + [S1.publicKey, S2.publicKey], + [sign(S1, digest), sign(S3, digest)], + ), + ).rejects.toThrow('Multisig: invalid signature'); + }); + it('should reject wrong token color', async () => { const wrongColor = new Uint8Array(32).fill(0xde); const coin = makeQualifiedCoin(wrongColor, 100n); + const digest = await burnDigest(multisig, 100n); await expect( - multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]), + multisig.burn( + coin, + 100n, + [S1.publicKey, S2.publicKey], + [sign(S1, digest), sign(S2, digest)], + ), ).rejects.toThrow('Multisig: coin not from this contract'); }); it('should reject insufficient coin value', async () => { const coin = makeQualifiedCoin(await multisig.getTokenType(), 10n); + const digest = await burnDigest(multisig, 100n); await expect( - multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]), + multisig.burn( + coin, + 100n, + [S1.publicKey, S2.publicKey], + [sign(S1, digest), sign(S2, digest)], + ), ).rejects.toThrow('Multisig: insufficient coin value'); }); it('should reject when amount exceeds value by 1', async () => { const coin = makeQualifiedCoin(await multisig.getTokenType(), 99n); + const digest = await burnDigest(multisig, 100n); await expect( - multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]), + multisig.burn( + coin, + 100n, + [S1.publicKey, S2.publicKey], + [sign(S1, digest), sign(S2, digest)], + ), ).rejects.toThrow('Multisig: insufficient coin value'); }); }); describe('domain separation', () => { - // Read-only on `multisig`, but runs after the mutating groups above, so - // deploy a clean shared instance for the group. beforeAll(async () => { multisig = await freshMultisig(); }); it('should isolate signers across instances with different salts', async () => { const salt2 = new Uint8Array(32).fill(0xcc); - const c1 = await multisig._calculateSignerId(PK1, INSTANCE_SALT); - const c2 = await multisig._calculateSignerId(PK1, salt2); + const c1 = await multisig._calculateSignerId(S1.publicKey, INSTANCE_SALT); + const c2 = await multisig._calculateSignerId(S1.publicKey, salt2); expect(c1).not.toEqual(c2); }); @@ -468,48 +519,40 @@ describe('ShieldedMultiSigV3', () => { it('should increment monotonically', async () => { for (let i = 0; i < 5; i++) { - await multisig.mint( - 1n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); + await mint(multisig, 1n, USER_RECIPIENT, [S1, S2]); expect(await multisig.getNonce()).toEqual(BigInt(i + 1)); } }); }); describe('cross-instance replay', () => { - beforeEach(async () => { - multisig = await freshMultisig(); - }); + // A distinct deployed address for the second instance, so its message + // hash (which commits to `kernel.self()`) differs from the first's. + const OTHER_ADDRESS = '11'.repeat(32); - it('should derive different message hashes for different instances', async () => { + it('should reject a signature bound to another instance', async () => { + const instance1 = await freshMultisig(); const instance2 = await ShieldedMultiSigV3Simulator.create( INSTANCE_SALT, INIT_COIN_NONCE, TOKEN_DOMAIN, SIGNER_COMMITMENTS, + { contractAddress: OTHER_ADDRESS }, ); - // With stub verification, both succeed independently. - // Once real ECDSA is available, a signature produced for one - // instance's message hash must not validate against the other's. - await multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - await instance2.mint( - 100n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); + // Signatures produced for instance1's message hash... + const digest1 = await mintDigest(instance1, USER_RECIPIENT, 100n); + const pubkeys = [S1.publicKey, S2.publicKey]; + const sigs = [sign(S1, digest1), sign(S2, digest1)]; - expect(await multisig.getNonce()).toEqual(1n); - expect(await instance2.getNonce()).toEqual(1n); + // ...authorize instance1... + await instance1.mint(100n, USER_RECIPIENT, pubkeys, sigs); + expect(await instance1.getNonce()).toEqual(1n); + + // ...but are rejected by instance2 (different `kernel.self()` → different hash). + await expect( + instance2.mint(100n, USER_RECIPIENT, pubkeys, sigs), + ).rejects.toThrow('Multisig: invalid signature'); }); }); }); diff --git a/contracts/src/multisig/test/simulators/ShieldedMultiSigV2Simulator.ts b/contracts/src/multisig/test/simulators/ShieldedMultiSigV2Simulator.ts index 22fdfd0e..aa73c596 100644 --- a/contracts/src/multisig/test/simulators/ShieldedMultiSigV2Simulator.ts +++ b/contracts/src/multisig/test/simulators/ShieldedMultiSigV2Simulator.ts @@ -1,3 +1,4 @@ +import type { Secp256k1Point } from '@midnight-ntwrk/compact-runtime'; import { createSimulator, type SimulatorOptions, @@ -8,6 +9,7 @@ import { pureCircuits, Contract as ShieldedMultiSigV2, } from '../../../../artifacts/ShieldedMultiSigV2/contract/index.js'; +import type { EcdsaSignature } from '../EcdsaTestUtils.js'; import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; type Recipient = { kind: number; address: Uint8Array }; @@ -67,7 +69,7 @@ export class ShieldedMultiSigV2Simulator extends ShieldedMultiSigV2SimulatorBase } public static calculateSignerId( - pk: Uint8Array, + pk: Secp256k1Point, salt: Uint8Array, ): Uint8Array { return pureCircuits._calculateSignerId(pk, salt); @@ -81,8 +83,8 @@ export class ShieldedMultiSigV2Simulator extends ShieldedMultiSigV2SimulatorBase to: Recipient, amount: bigint, coin: QualifiedShieldedCoinInfo, - pubkeys: Uint8Array[], - signatures: Uint8Array[], + pubkeys: Secp256k1Point[], + signatures: EcdsaSignature[], ): Promise { return this.circuits.impure.execute(to, amount, coin, pubkeys, signatures); } diff --git a/contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts b/contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts index 072e7875..56326a26 100644 --- a/contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts +++ b/contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts @@ -2,6 +2,7 @@ import { createSimulator, type SimulatorOptions, } from '@openzeppelin/compact-simulator'; +import type { Secp256k1Point } from '@midnight-ntwrk/compact-runtime'; import { type ContractAddress, type Either, @@ -10,6 +11,7 @@ import { Contract as ShieldedMultiSigV3Contract, type ZswapCoinPublicKey, } from '../../../../artifacts/ShieldedMultiSigV3/contract/index.js'; +import type { EcdsaSignature } from '../EcdsaTestUtils.js'; import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; type ShieldedMultiSigV3Args = readonly [ @@ -59,7 +61,7 @@ export class ShieldedMultiSigV3Simulator extends ShieldedMultiSigV3SimulatorBase } public _calculateSignerId( - pk: Uint8Array, + pk: Secp256k1Point, salt: Uint8Array, ): Promise { return this.circuits.pure._calculateSignerId(pk, salt); @@ -68,8 +70,8 @@ export class ShieldedMultiSigV3Simulator extends ShieldedMultiSigV3SimulatorBase public mint( amount: bigint, recipient: Either, - pubkeys: Uint8Array[], - signatures: Uint8Array[], + pubkeys: Secp256k1Point[], + signatures: EcdsaSignature[], ): Promise<[]> { return this.circuits.impure.mint(amount, recipient, pubkeys, signatures); } @@ -82,8 +84,8 @@ export class ShieldedMultiSigV3Simulator extends ShieldedMultiSigV3SimulatorBase mt_index: bigint; }, amount: bigint, - pubkeys: Uint8Array[], - signatures: Uint8Array[], + pubkeys: Secp256k1Point[], + signatures: EcdsaSignature[], ): Promise<[]> { return this.circuits.impure.burn(coin, amount, pubkeys, signatures); } @@ -117,7 +119,7 @@ export class ShieldedMultiSigV3Simulator extends ShieldedMultiSigV3SimulatorBase // domain ("multisig:signer:"). Pure standalone circuit so commitments can be // calculated before contract instantiation. export function calculateSignerId( - pk: Uint8Array, + pk: Secp256k1Point, salt: Uint8Array, ): Uint8Array { return pureCircuits._calculateSignerId(pk, salt); diff --git a/contracts/test-utils/fixtures/address.ts b/contracts/test-utils/fixtures/address.ts index 36bece39..16d7046a 100644 --- a/contracts/test-utils/fixtures/address.ts +++ b/contracts/test-utils/fixtures/address.ts @@ -1,5 +1,4 @@ import { - convertFieldToBytes, type EncodedContractAddress, encodeCoinPublicKey, isContractAddress, @@ -116,8 +115,7 @@ export const generateEitherPubKeyPair = (str: string) => Either, ]; -export const zeroUint8Array = (length = 32) => - convertFieldToBytes(length, 0n, ''); +export const zeroUint8Array = (length = 32) => new Uint8Array(length); export const ZERO_KEY = { is_left: true, From 77a69c1e1baa32804cbce6018673426d8886483c Mon Sep 17 00:00:00 2001 From: andrew Date: Sun, 26 Jul 2026 13:03:53 -0300 Subject: [PATCH 3/4] improve docs --- contracts/src/multisig/presets/ShieldedMultiSigV2.compact | 2 +- contracts/src/multisig/presets/ShieldedMultiSigV3.compact | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/src/multisig/presets/ShieldedMultiSigV2.compact b/contracts/src/multisig/presets/ShieldedMultiSigV2.compact index 350f4641..c3cbd3c4 100644 --- a/contracts/src/multisig/presets/ShieldedMultiSigV2.compact +++ b/contracts/src/multisig/presets/ShieldedMultiSigV2.compact @@ -131,7 +131,7 @@ export circuit deposit(coin: ShieldedCoinInfo): [] { * is verified against the message hash. Duplicate signers are rejected * via inequality check on adjacent commitments. * - * @notice Signatures are verified with real secp256k1 ECDSA + * @notice Signatures are verified with secp256k1 ECDSA * (`secp256k1EcdsaVerify`) over the persistentHash message digest. * * @notice Duplicate detection via != only works for exactly 2 signers. diff --git a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact index 5dac0fa2..25fda2b4 100644 --- a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact +++ b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact @@ -33,7 +33,7 @@ pragma language_version >= 0.23.0; * hash prevent signatures for one operation type from being replayed as * the other. * - * @notice Threshold authorization is enforced with real secp256k1 ECDSA + * @notice Threshold authorization is enforced with secp256k1 ECDSA * verification (`secp256k1EcdsaVerify`), and all operation/commitment hashing * uses `keccak256` to match the custodian's Ethereum-style HSM signing format. */ From 771eaeb3865789dd787b29880d24cc92b44771e0 Mon Sep 17 00:00:00 2001 From: andrew Date: Sun, 26 Jul 2026 13:06:22 -0300 Subject: [PATCH 4/4] update doc --- .../src/multisig/presets/ShieldedMultiSigV3.compact | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact index 25fda2b4..80e42ba3 100644 --- a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact +++ b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact @@ -35,7 +35,7 @@ pragma language_version >= 0.23.0; * * @notice Threshold authorization is enforced with secp256k1 ECDSA * verification (`secp256k1EcdsaVerify`), and all operation/commitment hashing - * uses `keccak256` to match the custodian's Ethereum-style HSM signing format. + * uses `keccak256`. */ import CompactStandardLibrary; @@ -140,9 +140,8 @@ constructor( * after the counter has been incremented, binding each mint's nonce to a * distinct counter value. * - * @notice The message hash is computed with `keccak256` to match the - * custodian's Ethereum-style HSM signing format, and each signature is - * checked with `secp256k1EcdsaVerify`. + * @notice The message hash is computed with `keccak256` and each + * signature is checked with `secp256k1EcdsaVerify`. * * Requirements: * @@ -215,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 The message hash is computed with `keccak256` to match the - * custodian's Ethereum-style HSM signing format, and each signature is - * checked with `secp256k1EcdsaVerify`. + * @notice The message hash is computed with `keccak256` and each + * signature is checked with `secp256k1EcdsaVerify`. * * Requirements: *