Skip to content
Open
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
368 changes: 368 additions & 0 deletions contracts/src/multisig/presets/ShieldedMultiSigV3.compact
Original file line number Diff line number Diff line change
@@ -0,0 +1,368 @@
// SPDX-License-Identifier: MIT
// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/presets/ShieldedMultiSigV3.compact)

pragma language_version >= 0.21.0;

/**
* @title ShieldedMultiSigV3
* @description Privacy-preserving multisig token contract for tokenised
* deposits. Both minting and burning require threshold ECDSA
* authorization. No single party can unilaterally create or destroy tokens.
*
* Designed with the following features:
* - Supply only enters through authorized mint (no open deposit)
* - Supply only exits through authorized burn (no arbitrary transfers)
* - Non-transferability enforced by absence of transfer/execute circuits
* - Change coins from partial burns handled by the transaction layer
* - Operator discovers coins via ZswapOutput events from the indexer
*
* Uses Midnight's native shielded token primitives:
*
* - `mint` creates a new UTXO via `mintShieldedToken`, addressed to the
* contract. No external coin input; supply is produced on-chain.
* - `burn` consumes a UTXO via `receiveShielded` + `sendShielded` to
* `burnAddress()`. Only coins of this contract's token type can be
* burned. Change is handled automatically by the transaction layer.
*
* Signer identities are stored as commitments: hashes of ECDSA public keys
* combined with an instance salt and domain separator. A counter
* provides replay protection, and also feeds `evolveNonce` to produce
* unique coin nonces on each mint.
*
* Operation domain prefixes ("multisig:mint:" / "multisig:burn:") in the message
* 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.
*/

import CompactStandardLibrary;

import "../Signer"<Bytes<32>> prefix Signer_;

// ─── Types ──────────────────────────────────────────────────────

/**
* @description Accumulator for fold-based signature verification.
* Threads the valid count, previous commitment (for duplicate
* detection), and message hash through each iteration.
*/
struct VerificationState {
validCount: Uint<8>,
prevCommitment: Bytes<32>,
msgHash: Bytes<32>
}

/**
* @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.
*/
struct SignerCommitmentInput {
pk: Bytes<64>,
salt: Bytes<32>,
domain: Bytes<32>
}

// ─── State ──────────────────────────────────────────────────────

export ledger _counter: Counter;
export ledger _coinNonce: Bytes<32>;
export ledger _instanceSalt: Bytes<32>;
export sealed ledger _tokenDomain: Bytes<32>;

// ─── Constructor ────────────────────────────────────────────────

/**
* @description Deploys the contract with 3 signer commitments and
* a threshold.
*
* Each commitment is computed off-chain as:
* `persistentHash(SignerCommitmentInput { pk, instanceSalt, domain })`
* where domain is `pad(32, "multisig:signer:")`.
*
* `tokenDomain` is used with `kernel.self()` to derive the token color
* via `tokenType(_tokenDomain, kernel.self())`. Only coins of this color
* can be burned through this contract.
*
* `initCoinNonce` seeds the `evolveNonce` chain used to produce unique
* mint nonces. It must be cryptographically random.
*
* Requirements:
*
* - `thresh` must be > 0 and <= 2.
* - `signerCommitments` must not contain duplicates.
* - `instanceSalt` and `initCoinNonce` should be cryptographically random.
*
* @param {Bytes<32>} instanceSalt - Random salt for signer commitment derivation.
* @param {Bytes<32>} initCoinNonce - Initial coin nonce seed.
* @param {Bytes<32>} tokenDomain - Domain string used to derive this contract's token color.
* @param {Vector<3, Bytes<32>>} signerCommitments - Hashed signer identities.
* @param {Uint<8>} thresh - Minimum approvals required.
*/
constructor(
instanceSalt: Bytes<32>,
initCoinNonce: Bytes<32>,
tokenDomain: Bytes<32>,
signerCommitments: Vector<3, Bytes<32>>,
thresh: Uint<8>,
) {
assert(
thresh <= 2,
"Multisig: threshold cannot exceed 2 (circuits verify at most 2 signatures)"
);
_instanceSalt = disclose(instanceSalt);
_coinNonce = disclose(initCoinNonce);
_tokenDomain = disclose(tokenDomain);
Signer_initialize<3>(signerCommitments, thresh);
}

// ─── Mint ───────────────────────────────────────────────────────

/**
* @description Mints a new shielded coin addressed to this contract,
* authorized by threshold signatures.
*
* Creates a new UTXO of this contract's token type via `mintShieldedToken`,
* addressed to the contract itself — (false, contract_address). No external
* coin input is required; supply is produced on-chain.
*
* The operator discovers the new UTXO via ZswapOutput events from the
* indexer and adds it to the off-chain UTXO pool for future burn operations.
*
* The message hash commits to: operation domain ("multisig:mint:"), current
* counter value (replay protection), and the amount. The domain prefix
* ensures mint signatures cannot be replayed as burn operations.
*
* Coin nonce uniqueness is guaranteed by `evolveNonce(_counter, _coinNonce)`
* 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.
*
* Requirements:
*
* - Both public keys must hash to registered signer commitments.
* - Both signatures must be valid over the mint message hash.
* - Signers must not be duplicates.
* - Threshold must be met.
*
* @param {Uint<64>} amount - The token amount to mint.
* @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers.
* @param {Vector<2, Bytes<64>>} signatures - ECDSA signatures over the mint hash.
*/
export circuit mint(
amount: Uint<64>,
pubkeys: Vector<2, Bytes<64>>,
signatures: Vector<2, Bytes<64>>
): [] {
const opNonce = _counter;
_counter.increment(1);

const msgHash = persistentHash<Vector<4, Bytes<32>>>([
pad(32, "multisig:mint:"),
kernel.self().bytes,
opNonce as Bytes<32>,
amount as Bytes<32>
]);

const initialState = VerificationState {
validCount: 0 as Uint<8>,
prevCommitment: pad(32, ""),
msgHash: msgHash
};

const finalState = fold(verifySignature, initialState, pubkeys, signatures);
Signer_assertThresholdMet(finalState.validCount);

_coinNonce = evolveNonce(_counter, _coinNonce);
const contractRecipient = right<ZswapCoinPublicKey, ContractAddress>(kernel.self());
mintShieldedToken(_tokenDomain, disclose(amount), _coinNonce, disclose(contractRecipient));
}

// ─── Burn ───────────────────────────────────────────────────────

/**
* @description Burns a shielded coin of this contract's token type,
* authorized by threshold signatures.
*
* Receives the coin via `receiveShielded` then sends the specified amount
* to `burnAddress()` via `sendShielded`. The nullifier is submitted on-chain,
* permanently marking the UTXO as spent.
*
* Change handling is automatic: if `amount < coin.value`, the transaction
* layer creates a change output addressed back to the contract. The operator
* discovers this change coin via `nextZswapLocalState.outputs` in the
* transaction's private result, then discovers its mt_index from the
* indexer events using the standard flow. No contract-level change logic
* is required.
*
* Only coins of this contract's token type can be burned. The operator
* supplies the `QualifiedShieldedCoinInfo` from the off-chain UTXO pool.
*
* 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.
*
* Requirements:
*
* - Both public keys must hash to registered signer commitments.
* - Both signatures must be valid over the burn message hash.
* - Signers must not be duplicates.
* - Threshold must be met.
* - coin.color must equal tokenType(_tokenDomain, kernel.self()).
* - coin.value must be >= amount.
*
* @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.
*/
export circuit burn(
coin: QualifiedShieldedCoinInfo,
amount: Uint<64>,
pubkeys: Vector<2, Bytes<64>>,
signatures: Vector<2, Bytes<64>>
): [] {
const opNonce = _counter;
_counter.increment(1);

const msgHash = persistentHash<Vector<4, Bytes<32>>>([
pad(32, "multisig:burn:"),
kernel.self().bytes,
opNonce as Bytes<32>,
amount as Bytes<32>
]);

const initialState = VerificationState {
validCount: 0 as Uint<8>,
prevCommitment: pad(32, ""),
msgHash: msgHash
};

const finalState = fold(verifySignature, initialState, pubkeys, signatures);
Signer_assertThresholdMet(finalState.validCount);

assert(coin.color == tokenType(_tokenDomain, kernel.self()), "Multisig: coin not from this contract");
assert(coin.value >= amount, "Multisig: insufficient coin value");

const _coin = ShieldedCoinInfo {
nonce: coin.nonce,
color: coin.color,
value: coin.value
};
receiveShielded(disclose(_coin));
sendShielded(disclose(coin), shieldedBurnAddress(), disclose(amount));
}

// ─── Signature Verification ─────────────────────────────────────

/**
* @description Fold callback. Verifies one signer's approval.
*
* Computes the signer's commitment from their public key and the instance
* salt, checks for duplicates against the previous commitment, verifies
* registry membership, and validates the ECDSA signature.
*
* @notice Circuit signatures are fixed at Vector<2, ...>.
* This contract supports at most 2-of-N threshold configurations.
* 3-of-N or higher requires a separate contract variant with a larger vector and a
* 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.
* @returns {VerificationState} Updated accumulator.
*/
circuit verifySignature(
state: VerificationState,
pubkey: Bytes<64>,
signature: Bytes<64>
): VerificationState {
const commitment = _calculateSignerId(pubkey, _instanceSalt);

// Duplicate detection — sufficient for 2 signers only
assert(commitment != state.prevCommitment, "Multisig: duplicate signer");

Signer_assertSigner(commitment);

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

return VerificationState {
validCount: state.validCount + 1 as Uint<8>,
prevCommitment: commitment,
msgHash: state.msgHash
};
}

/**
* @description Computes a signer commitment from an ECDSA public key.
*
* The commitment is persistentHash(pk, salt, domain) where:
* - pk: the signer's ECDSA public key (64 bytes)
* - 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 {Bytes<32>} salt - The instance salt.
* @returns {Bytes<32>} The signer commitment.
*/
export pure circuit _calculateSignerId(
pk: Bytes<64>,
salt: Bytes<32>
): Bytes<32> {
return persistentHash<SignerCommitmentInput>(SignerCommitmentInput {
pk: pk,
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> {
return _counter;
}

export circuit getTokenDomain(): Bytes<32> {
return _tokenDomain;
}

export circuit getTokenType(): Bytes<32> {
return tokenType(_tokenDomain, kernel.self());
}

export circuit getSignerCount(): Uint<8> {
return Signer_getSignerCount();
}

export circuit getThreshold(): Uint<8> {
return Signer_getThreshold();
}

export circuit isSigner(commitment: Bytes<32>): Boolean {
return Signer_isSigner(commitment);
}
Loading
Loading