DestinationRestriction
The DestinationRestriction policy restricts which address can receive funds during a reveal. It supports two modes: a single-address restriction and a Merkle allowlist for multiple permitted destinations. The mode is determined by the length of the policyParams.
Deployed address: 0x584F2c7F6da6f25a7bF6A1F3D7F422683Ac52Ef1
Interface
contract DestinationRestriction is IRevealPolicy {
function validateReveal(
bytes32 commitment,
bytes32 nullifier,
address recipient,
uint256 amount,
address token,
bytes calldata policyParams
) external view override returns (bool valid);
}
Modes
Mode 1: Single Address (policyParams length == 32 bytes)
When policyParams is exactly 32 bytes, it is interpreted as a single allowed destination address (left-padded to bytes32).
bytes memory policyParams = abi.encode(allowedAddress);
Validation:
address allowed = abi.decode(policyParams, (address));
return recipient == allowed;
The reveal succeeds only if the recipient in the ZK proof's public inputs matches the committed address.
Mode 2: Merkle Allowlist (policyParams length > 32 bytes)
When policyParams is longer than 32 bytes, it is interpreted as a Merkle proof that the recipient is a member of a pre-committed allowlist.
bytes memory policyParams = abi.encode(merkleRoot, merkleProof);
Where:
merkleRoot(bytes32): Root of the allowlist Merkle tree. This root must match what was committed (i.e., thepolicyParamsHashat commit time iskeccak256(abi.encode(merkleRoot))... see details below).merkleProof(bytes32[]): Merkle inclusion proof for therecipient.
Validation:
(bytes32 merkleRoot, bytes32[] memory proof) = abi.decode(
policyParams,
(bytes32, bytes32[])
);
// Verify recipient is in the allowlist
bytes32 leaf = keccak256(abi.encodePacked(recipient));
return MerkleProof.verify(proof, merkleRoot, leaf);
The reveal succeeds only if a valid Merkle proof is provided demonstrating that the recipient is a leaf in the allowlist tree.
Parameter Encoding
Single Address Mode
// At commit time:
address allowedRecipient = 0x1234...;
bytes memory params = abi.encode(allowedRecipient);
bytes32 paramsHash = keccak256(params);
vault.commitWithPolicy(token, amount, commitment, qCommitment, policyAddress, paramsHash);
// At reveal time:
vault.reveal(token, proof, publicInputs, commitment, qProof, changeQCommitment, params);
Merkle Allowlist Mode
// Off-chain: build a Merkle tree from allowed addresses
// Leaves: keccak256(abi.encodePacked(address))
bytes32 merkleRoot = computeMerkleRoot(allowedAddresses);
// At commit time:
bytes memory params = abi.encode(merkleRoot, new bytes32[](0)); // placeholder proof
bytes32 paramsHash = keccak256(abi.encode(merkleRoot)); // hash only the root
// IMPORTANT: paramsHash commits to the root only, not the proof.
// The actual proof varies per recipient.
vault.commitWithPolicy(token, amount, commitment, qCommitment, policyAddress, paramsHash);
// At reveal time (for a specific recipient):
bytes32[] memory proof = generateMerkleProof(allowedAddresses, recipient);
bytes memory revealParams = abi.encode(merkleRoot, proof);
vault.reveal(token, proof_, publicInputs, commitment, qProof, changeQCommitment, revealParams);
For the Merkle allowlist mode, the policyParamsHash stored at commit time must be keccak256(abi.encode(merkleRoot)) -- hashing only the root, not the proof. The proof is variable per recipient and is validated at reveal time against the committed root.
Usage Examples
Restricting to a Single Withdrawal Address
// Only allow withdrawal to a specific cold wallet
address coldWallet = 0xAbCd...1234;
bytes memory params = abi.encode(coldWallet);
bytes32 paramsHash = keccak256(params);
vault.commitWithPolicy(
tokenAddress,
amount,
commitment,
quantumCommitment,
0x584F2c7F6da6f25a7bF6A1F3D7F422683Ac52Ef1, // DestinationRestriction
paramsHash
);
Allowing Withdrawal to Any of N Addresses
// Off-chain: build the Merkle tree
const { MerkleTree } = require("merkletreejs");
const { keccak256 } = require("ethers/lib/utils");
const allowedAddresses = [address1, address2, address3, address4];
const leaves = allowedAddresses.map((a) =>
keccak256(ethers.utils.solidityPack(["address"], [a]))
);
const tree = new MerkleTree(leaves, keccak256, { sortPairs: true });
const merkleRoot = tree.getHexRoot();
// Commit with the Merkle root
const paramsHash = keccak256(ethers.utils.defaultAbiCoder.encode(["bytes32"], [merkleRoot]));
// At reveal time, generate proof for the chosen recipient
bytes32[] memory proof = getMerkleProof(recipient);
bytes memory params = abi.encode(merkleRoot, proof);
vault.reveal(token, zkProof, publicInputs, commitment, qProof, changeQCommitment, params);
Gas Usage
| Mode | Operations | Approximate Gas |
|---|---|---|
| Single address | ABI decode + address comparison | ~500 |
| Merkle allowlist (depth 10) | ABI decode + 10 hashes + comparisons | ~5,000 |
| Merkle allowlist (depth 20) | ABI decode + 20 hashes + comparisons | ~9,000 |
Both modes are well within the 100,000 gas cap.
Security Considerations
- Single address mode provides the strongest guarantee: funds can only go to one specific address. This is useful for self-transfers to a cold wallet or pre-arranged payments.
- Merkle allowlist mode provides flexibility while maintaining control. The set of allowed recipients is committed at deposit time and cannot be changed afterward.
- Recipient binding: The
recipientvalidated by this policy is extracted from the ZK proof's public inputs. It cannot be spoofed because it is verified as part of the proof circuit. - Allowlist privacy: The full list of allowed addresses is not stored on-chain. Only the Merkle root (via its hash) is committed. Individual addresses are revealed only when a withdrawal is made to them.