Skip to main content

ThresholdWitness

The ThresholdWitness policy requires M-of-N witness signatures to authorize a reveal. This enables multi-party approval for withdrawals -- useful for institutional custody, social recovery, and governance-controlled funds.

Deployed address: 0x5814e4755C0D98218ddb752D26dD03feba428c80

Interface

contract ThresholdWitness is IRevealPolicy {
function validateReveal(
bytes32 commitment,
bytes32 nullifier,
address recipient,
uint256 amount,
address token,
bytes calldata policyParams
) external view override returns (bool valid);
}

Message Format

Each witness signs a message constructed as:

bytes32 message = keccak256(
abi.encodePacked(commitment, nullifier, recipient, amount, token)
);

This binds each signature to the specific reveal operation. A signature valid for one reveal cannot be replayed for a different commitment, recipient, or amount.

Witnesses sign the Ethereum signed message hash (EIP-191):

bytes32 ethSignedMessage = keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32", message)
);

Policy Parameters

The policyParams are ABI-encoded as:

bytes memory policyParams = abi.encode(threshold, witnesses, signatures);
ParameterTypeDescription
thresholduint256Minimum number of valid signatures required (M).
witnessesaddress[]Ordered list of all possible witness addresses (N).
signaturesbytes[]Array of ECDSA signatures, one per witness. Use empty bytes ("") for witnesses that did not sign.

Parameter Hash at Commit Time

At commit time, only the threshold and witnesses are committed (not the signatures, which are produced at reveal time):

bytes32 paramsHash = keccak256(abi.encode(threshold, witnesses));

Validation Logic

function validateReveal(
bytes32 commitment,
bytes32 nullifier,
address recipient,
uint256 amount,
address token,
bytes calldata policyParams
) external view override returns (bool valid) {
(
uint256 threshold,
address[] memory witnesses,
bytes[] memory signatures
) = abi.decode(policyParams, (uint256, address[], bytes[]));

require(signatures.length == witnesses.length, "Length mismatch");

bytes32 message = keccak256(
abi.encodePacked(commitment, nullifier, recipient, amount, token)
);
bytes32 ethSignedMessage = keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32", message)
);

uint256 validCount = 0;
for (uint256 i = 0; i < witnesses.length; i++) {
if (signatures[i].length == 65) {
address signer = ecrecover(ethSignedMessage, v, r, s); // decomposed from signatures[i]
if (signer == witnesses[i]) {
validCount++;
}
}
}

return validCount >= threshold;
}

The function:

  1. Decodes threshold, witnesses, and signatures from policyParams.
  2. Reconstructs the message hash from the reveal parameters.
  3. Iterates through each witness, verifying their signature via ecrecover.
  4. Returns true if at least threshold valid signatures are present.

Usage Examples

Setting Up a 2-of-3 Multisig Policy

// Define witnesses
address[] memory witnesses = new address[](3);
witnesses[0] = 0xAlice...;
witnesses[1] = 0xBob...;
witnesses[2] = 0xCarol...;

uint256 threshold = 2;

// Commit time: hash only threshold and witnesses
bytes32 paramsHash = keccak256(abi.encode(threshold, witnesses));

vault.commitWithPolicy(
tokenAddress,
amount,
commitment,
quantumCommitment,
0x5814e4755C0D98218ddb752D26dD03feba428c80, // ThresholdWitness
paramsHash
);

Collecting Signatures and Revealing

// Off-chain: compute the message
const message = ethers.utils.solidityKeccak256(
["bytes32", "bytes32", "address", "uint256", "address"],
[commitment, nullifier, recipient, amount, token]
);

// Each witness signs the message
const aliceSig = await alice.signMessage(ethers.utils.arrayify(message));
const bobSig = await bob.signMessage(ethers.utils.arrayify(message));
// Carol did not sign
const carolSig = "0x";
// At reveal time:
bytes[] memory signatures = new bytes[](3);
signatures[0] = aliceSig; // valid
signatures[1] = bobSig; // valid
signatures[2] = ""; // Carol didn't sign

bytes memory params = abi.encode(threshold, witnesses, signatures);

vault.reveal(
tokenAddress,
proof,
publicInputs,
commitment,
quantumProof,
changeQuantumCommitment,
params
);
// Succeeds: 2 valid signatures >= threshold of 2

1-of-1 (Single Approver)

address[] memory witnesses = new address[](1);
witnesses[0] = approverAddress;
uint256 threshold = 1;

bytes32 paramsHash = keccak256(abi.encode(threshold, witnesses));

N-of-N (Unanimous)

uint256 threshold = witnesses.length; // all must sign
bytes32 paramsHash = keccak256(abi.encode(threshold, witnesses));

Gas Usage

N (witnesses)Approximate Gas
1~4,500
3~12,000
5~19,500
7~27,000
10~38,000

Each ecrecover costs approximately 3,000 gas. With ABI decoding and loop overhead, the practical maximum within the 100,000 gas cap is approximately 25-30 witnesses.

Security Considerations

  • Signature binding: Signatures are bound to the specific (commitment, nullifier, recipient, amount, token) tuple. They cannot be reused across different reveals.
  • Witness ordering: The witnesses array must be provided in the same order at both commit and reveal time, since the policyParamsHash includes the witness array encoding.
  • Empty signatures: Non-signing witnesses must provide empty bytes. The contract skips signatures that are not exactly 65 bytes long.
  • No replay: Each reveal has a unique nullifier, so a signature set valid for one reveal is not valid for any other.
  • Signer recovery: The contract uses ecrecover, which returns address(0) for invalid signatures. Since address(0) is never a valid witness, invalid signatures are safely ignored.