Skip to main content

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., the policyParamsHash at commit time is keccak256(abi.encode(merkleRoot))... see details below).
  • merkleProof (bytes32[]): Merkle inclusion proof for the recipient.

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);
caution

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

ModeOperationsApproximate Gas
Single addressABI 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 recipient validated 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.