Skip to main content

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:

IndexFieldTypeDescription
0rootbytes32Merkle tree root the commitment belongs to
1nullifierbytes32Deterministic nullifier derived from commitment secrets
2amountuint256Token amount being revealed
3recipientaddressAddress receiving the tokens/data
4changeCommitmentbytes32New commitment for any unrevealed remainder (0 if full reveal)
5tokenIdbytes32Poseidon hash of the token contract address
6policyIdaddressPolicy contract address (0 if no policy)
7policyParamsHashbytes32Hash 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:

  1. In the commitment -- policyParamsHash is one of the 7 inputs to the Poseidon hash
  2. On-chain during reveal -- the vault recomputes keccak256(policyParams) % p from 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 be 0 (no policy contract)
  • publicInputs[7] must be 0 (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

PropertyMechanism
Policy bound to commitmentpolicyId and policyParamsHash are Poseidon hash inputs
Prover cannot omit policyDummy quadratic constraints + Merkle membership
Parameters verified on-chainkeccak256(policyParams) % BN254_FIELD checked against proof
Policy logic executed on-chainstaticcall to IRevealPolicy.validate() with 100K gas cap
No-policy defaultBoth fields must be zero; any other combination invalidates the proof