Circuit-Level Policy Binding
Policies in the Specter protocol are not enforced by trust or convention. They are embedded as public inputs in the ZK proof itself, making them cryptographically inseparable from the commitment they constrain. This page explains how policyId and policyParamsHash are bound into the circuit, why they cannot be forged, and how on-chain verification confirms them.
Public Inputs
The token redemption circuit (7-input commitment variant) produces 8 public inputs that are visible to the on-chain verifier:
| Index | Field | Type | Description |
|---|---|---|---|
| 0 | root | bytes32 | Merkle tree root the commitment belongs to |
| 1 | nullifier | bytes32 | Deterministic nullifier derived from commitment secrets |
| 2 | amount | uint256 | Token amount being revealed |
| 3 | recipient | address | Address receiving the tokens/data |
| 4 | changeCommitment | bytes32 | New commitment for any unrevealed remainder (0 if full reveal) |
| 5 | tokenId | bytes32 | Poseidon hash of the token contract address |
| 6 | policyId | address | Policy contract address (0 if no policy) |
| 7 | policyParamsHash | bytes32 | Hash of policy parameters, reduced to BN254 field |
Inputs 6 and 7 carry the policy binding. The prover must supply the correct policyId and policyParamsHash that were used when the original commitment was created. If the prover supplies different values, the Poseidon hash will not match the committed value in the Merkle tree, and the Merkle membership proof will fail.
Dummy Quadratic Constraints
In a ZK circuit, a public input that is declared but not constrained could theoretically be set to any value by the prover. To prevent this, the circuit includes dummy quadratic constraints that force the prover to include the actual policy values:
policyIdSquare === policyId * policyId
policyParamsHashSquare === policyParamsHash * policyParamsHash
These constraints serve a specific purpose: they ensure the circuit's constraint system references policyId and policyParamsHash as active wires. Without them, an optimizer could treat these inputs as unconstrained, allowing a prover to substitute arbitrary values. The quadratic constraint is the simplest form that prevents this -- it requires the prover to commit to a specific value for each field.
The key insight is that policyId and policyParamsHash are also inputs to the Poseidon commitment hash:
commitment = Poseidon7(secret, nullifierSecret, tokenId, amount, blinding, policyId, policyParamsHash)
Since the commitment must match a leaf in the Merkle tree, and the Merkle root is a public input verified on-chain, the prover cannot change policyId or policyParamsHash without also invalidating the Merkle proof. The dummy constraints provide a second layer of defense by ensuring these values are explicitly present in the proof's public outputs.
Computing policyParamsHash
The policyParamsHash is derived from the policy's ABI-encoded parameters:
policyParamsHash = keccak256(abi.encode(policyParams)) % BN254_SCALAR_FIELD
Where the BN254 scalar field prime is:
p = 21888242871839275222246405745257275088548364400416034343698204186575808495617
The modular reduction is necessary because keccak256 produces a 256-bit output, but the BN254 circuit operates over a ~254-bit field. Values exceeding the field prime are reduced modulo p by the Circom compiler. The on-chain verifier performs the same reduction to ensure the hash matches:
bytes32 reducedHash = bytes32(uint256(keccak256(policyParams)) % BN254_SCALAR_FIELD);
if (reducedHash != policyParamsHash) {
revert PolicyValidationFailed();
}
This means the policy parameters are bound twice:
- In the commitment --
policyParamsHashis one of the 7 inputs to the Poseidon hash - On-chain during reveal -- the vault recomputes
keccak256(policyParams) % pfrom the submitted parameters and checks it matches the proof's public input
On-Chain Verification Flow
When a reveal transaction is submitted, the vault contract performs the following checks related to policy binding:
Note that the ProofVerifier only receives 6 public inputs (indices 0-5). The policy fields (indices 6-7) are verified by the vault contract's own logic, not by the ZK verifier. This separation exists because the ZK circuit ensures the policy values are correctly bound to the commitment, while the vault contract handles the actual policy execution.
The No-Policy Case
When policyId == address(0), no policy is enforced. The circuit still includes the policy fields as public inputs, but both must be zero:
publicInputs[6]must be0(no policy contract)publicInputs[7]must be0(no parameters)
If policyId is zero but policyParamsHash is non-zero (or vice versa), the commitment hash will not match any valid leaf in the Merkle tree, and the proof will be invalid. The two values must be consistent with what was committed.
Security Properties
A malicious prover cannot forge a different policy
The policyId and policyParamsHash are inputs to the Poseidon commitment hash. Changing either value changes the commitment, which changes the Merkle leaf, which invalidates the Merkle membership proof. The prover would need to find a Poseidon hash collision to substitute a different policy -- this is computationally infeasible.
A malicious prover cannot remove a policy
Attempting to set policyId to address(0) when the original commitment was created with a non-zero policy will produce a different commitment hash. The Merkle proof will fail because the leaf does not exist in the tree.
A malicious prover cannot submit fake policy parameters
The on-chain verifier recomputes keccak256(policyParams) % p from the raw parameters submitted in the transaction. If the prover submits parameters that hash to a different value than what is in the proof's public inputs, the verification fails before the policy contract is even called.
Policy execution is sandboxed
The policy contract is called via staticcall with a 100,000 gas limit. It cannot modify state, cannot call other contracts that modify state, and cannot consume unbounded gas. A buggy or malicious policy contract can only cause a reveal to fail -- it cannot affect the vault's state or any other contract.
Summary
| Property | Mechanism |
|---|---|
| Policy bound to commitment | policyId and policyParamsHash are Poseidon hash inputs |
| Prover cannot omit policy | Dummy quadratic constraints + Merkle membership |
| Parameters verified on-chain | keccak256(policyParams) % BN254_FIELD checked against proof |
| Policy logic executed on-chain | staticcall to IRevealPolicy.validate() with 100K gas cap |
| No-policy default | Both fields must be zero; any other combination invalidates the proof |