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);
| Parameter | Type | Description |
|---|---|---|
threshold | uint256 | Minimum number of valid signatures required (M). |
witnesses | address[] | Ordered list of all possible witness addresses (N). |
signatures | bytes[] | 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:
- Decodes
threshold,witnesses, andsignaturesfrompolicyParams. - Reconstructs the message hash from the reveal parameters.
- Iterates through each witness, verifying their signature via
ecrecover. - Returns
trueif at leastthresholdvalid 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
witnessesarray must be provided in the same order at both commit and reveal time, since thepolicyParamsHashincludes 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 returnsaddress(0)for invalid signatures. Sinceaddress(0)is never a valid witness, invalid signatures are safely ignored.