Skip to main content

Programmable Policies

Programmable policies are reveal-time enforcement contracts that govern how, when, and to whom committed assets can be revealed. Policies are cryptographically bound to commitments at commit time and cannot be removed, changed, or bypassed after the fact — not by the user, not by an admin, and not by a partial withdrawal.

Design Principles

  1. Bound at commit time. The policyId and policyParamsHash are two of the seven inputs to the Poseidon7 commitment hash. They are inside the ZK circuit.
  2. Tamper-proof. Changing the policy would change the commitment, which would invalidate the Merkle proof. The ZK circuit enforces that the public policyId and policyParamsHash match the committed values.
  3. Inescapable. Change commitments (from partial withdrawals) carry the same policyId and policyParamsHash forward. You cannot split your way out of a policy.
  4. Stateless enforcement. Policy contracts are called via staticcall — they cannot modify state. They receive inputs and return a boolean.
  5. Gas-capped. Policy execution is limited to 100,000 gas to prevent denial-of-service via expensive policy logic.
  6. Permissionless. Anyone can deploy a policy contract. The PolicyRegistry is informational only.

How Policy Binding Works

At Commit Time

When a user creates a commitment with a policy, the policy information becomes part of the commitment preimage:

commitment = Poseidon7(
secret,
nullifierSecret,
tokenId,
amount,
blinding,
policyId, ← 6th input
policyParamsHash ← 7th input
)

The policyParamsHash is derived from the raw policy parameters:

uint256 policyParamsHash = uint256(keccak256(abi.encodePacked(policyParams))) % BN254_FIELD;

The field reduction (% BN254_FIELD) is necessary because keccak256 produces a 256-bit output that may exceed the BN254 scalar field prime p.

Inside the ZK Circuit

The circuit constrains:

// The public policyId must match the committed policyId
signal input policyId; // private
signal input pub_policyId; // public
policyId === pub_policyId;

// The public policyParamsHash must match the committed policyParamsHash
signal input policyParamsHash; // private
signal input pub_policyParamsHash; // public
policyParamsHash === pub_policyParamsHash;

This means the on-chain verifier receives policyId and policyParamsHash as public inputs and the ZK proof guarantees they match the values inside the commitment. An attacker cannot submit a different policy ID or different parameters — the proof would be invalid.

At Reveal Time

After the Groth16 proof is verified, the contract enforces the policy:

if (policyId != 0) {
address policyContract = policyRegistry.getPolicy(policyId);
require(policyContract != address(0), "Policy not found");

(bool success, bytes memory result) = policyContract.staticcall{gas: 100000}(
abi.encodeCall(
IRevealPolicy.validate,
(commitment, nullifier, recipient, amount, tokenAddress, policyParams)
)
);

require(success, "Policy call failed");
require(abi.decode(result, (bool)), "Policy validation failed");
}

Parameter Integrity

The contract verifies that the policyParams submitted at reveal time match the policyParamsHash that was committed:

uint256 computedHash = uint256(keccak256(abi.encodePacked(policyParams))) % BN254_FIELD;
require(computedHash == policyParamsHash, "Policy params mismatch");

This prevents an attacker from substituting different policy parameters at reveal time. The parameters are locked at commit time via the hash, and the hash is locked inside the ZK proof.

IRevealPolicy Interface

Every policy contract must implement the IRevealPolicy interface:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IRevealPolicy {
/**
* @notice Validates whether a reveal should be allowed
* @param commitment The original commitment being revealed
* @param nullifier The nullifier for this reveal
* @param recipient The address that will receive the tokens
* @param amount The amount being withdrawn
* @param token The token address being withdrawn
* @param policyParams ABI-encoded parameters specific to this policy
* @return valid True if the reveal is allowed, false to reject
*/
function validate(
uint256 commitment,
uint256 nullifier,
address recipient,
uint256 amount,
address token,
bytes calldata policyParams
) external view returns (bool valid);
}

Key constraints:

ConstraintMechanismReason
Read-onlystaticcallPolicy cannot modify state, mint tokens, or trigger side effects
Gas limit100,000 gas capPrevents DoS via expensive computation
Return typeboolSimple accept/reject — no partial amounts or redirects
ParametersVerified by hashCannot be tampered with at reveal time

Reference Policies

Specter ships three reference policy implementations that cover common use cases.

TimelockExpiry

Restricts reveals to a specific time window.

contract TimelockExpiry is IRevealPolicy {
function validate(
uint256, /* commitment */
uint256, /* nullifier */
address, /* recipient */
uint256, /* amount */
address, /* token */
bytes calldata policyParams
) external view override returns (bool) {
(uint256 notBefore, uint256 notAfter) = abi.decode(policyParams, (uint256, uint256));

if (notBefore > 0 && block.timestamp < notBefore) {
return false; // Too early
}
if (notAfter > 0 && block.timestamp > notAfter) {
return false; // Too late (expired)
}
return true;
}
}

Policy parameters:

ParameterTypeDescription
notBeforeuint256Earliest allowed reveal time (Unix timestamp). 0 = no lower bound
notAfteruint256Latest allowed reveal time (Unix timestamp). 0 = no upper bound

Use cases:

  • Vesting schedules: notBefore = vestingCliffTimestamp, notAfter = 0
  • Expiring vouchers: notBefore = 0, notAfter = expirationTimestamp
  • Time-boxed access: notBefore = startTime, notAfter = endTime

DestinationRestriction

Restricts which addresses can receive the revealed tokens.

contract DestinationRestriction is IRevealPolicy {
function validate(
uint256, /* commitment */
uint256, /* nullifier */
address recipient,
uint256, /* amount */
address, /* token */
bytes calldata policyParams
) external view override returns (bool) {
// Check encoding mode: single address or Merkle allowlist
if (policyParams.length == 32) {
// Single address mode
address allowed = abi.decode(policyParams, (address));
return recipient == allowed;
} else {
// Merkle allowlist mode
(bytes32 merkleRoot, bytes32[] memory proof) =
abi.decode(policyParams, (bytes32, bytes32[]));
bytes32 leaf = keccak256(abi.encodePacked(recipient));
return _verifyMerkleProof(proof, merkleRoot, leaf);
}
}

function _verifyMerkleProof(
bytes32[] memory proof,
bytes32 root,
bytes32 leaf
) internal pure returns (bool) {
bytes32 computedHash = leaf;
for (uint256 i = 0; i < proof.length; i++) {
if (computedHash <= proof[i]) {
computedHash = keccak256(abi.encodePacked(computedHash, proof[i]));
} else {
computedHash = keccak256(abi.encodePacked(proof[i], computedHash));
}
}
return computedHash == root;
}
}

Two modes:

ModeParameter EncodingDescription
Single addressabi.encode(address) (32 bytes)Only the specified address can receive tokens
Merkle allowlistabi.encode(bytes32, bytes32[])Recipient must be in the allowlist Merkle tree

Use cases:

  • Payroll: commit with DestinationRestriction(employeeAddress) — only the employee can reveal
  • Compliance: commit with a Merkle root of KYC-verified addresses
  • Escrow: commit with the counterparty's address, release only to them

ThresholdWitness

Requires M-of-N signatures from designated witnesses before reveal is allowed.

contract ThresholdWitness is IRevealPolicy {
function validate(
uint256, /* commitment */
uint256 nullifier,
address, /* recipient */
uint256, /* amount */
address, /* token */
bytes calldata policyParams
) external view override returns (bool) {
(
uint256 threshold,
address[] memory witnesses,
bytes[] memory signatures
) = abi.decode(policyParams, (uint256, address[], bytes[]));

require(signatures.length >= threshold, "Not enough signatures");

bytes32 message = keccak256(abi.encodePacked(
"SPECTER_WITNESS",
nullifier
));
bytes32 ethSignedHash = keccak256(abi.encodePacked(
"\x19Ethereum Signed Message:\n32",
message
));

uint256 validCount = 0;
for (uint256 i = 0; i < signatures.length; i++) {
address signer = _recover(ethSignedHash, signatures[i]);
if (_isWitness(signer, witnesses)) {
validCount++;
}
}

return validCount >= threshold;
}
}

Policy parameters:

ParameterTypeDescription
thresholduint256Minimum number of valid signatures required
witnessesaddress[]Addresses of authorized witnesses
signaturesbytes[]EIP-191 signatures over the nullifier

How it works:

  1. At commit time, the policyParams encode the threshold and witnesses list. The signatures array is empty (or a placeholder) — only the hash of the full params matters at commit time.
  2. Before reveal, the revealer contacts M witnesses and asks them to sign the nullifier.
  3. At reveal time, the revealer submits the signed messages as policyParams. The hash must still match policyParamsHash.

Important: Because policyParamsHash is committed at commit time, the witness list and threshold are fixed. However, the signatures are part of the params and change per reveal. This requires careful parameter construction: the committed hash must cover the threshold and witness list but use a placeholder for signatures. The policy contract then extracts and verifies accordingly.

Use cases:

  • Multi-sig custody: 2-of-3 witnesses must approve any withdrawal
  • Escrow release: arbiter + buyer must both sign
  • Corporate treasury: board members must co-sign

PolicyRegistry

The PolicyRegistry is an on-chain directory that maps policy IDs to contract addresses:

contract PolicyRegistry {
mapping(uint256 => address) public policies;
uint256 public nextPolicyId;

event PolicyRegistered(uint256 indexed policyId, address indexed policyContract);

function registerPolicy(address policyContract) external returns (uint256 policyId) {
policyId = nextPolicyId++;
policies[policyId] = policyContract;
emit PolicyRegistered(policyId, policyContract);
}

function getPolicy(uint256 policyId) external view returns (address) {
return policies[policyId];
}
}

Properties:

PropertyDetail
PermissionlessAnyone can register a policy contract
InformationalThe registry is for discoverability, not access control
Immutable mappingsOnce registered, a policy ID always points to the same contract
No upgradesPolicy contracts are not upgradeable — the commitment hash locks the policy

Writing a Custom Policy

Step 1: Define Your Validation Logic

Decide what conditions must be met at reveal time. Your policy contract has access to:

  • commitment — the commitment being revealed
  • nullifier — the unique spend tag
  • recipient — who will receive the tokens
  • amount — how many tokens are being withdrawn
  • token — which token address
  • policyParams — arbitrary bytes you define (ABI-encoded)

Step 2: Implement IRevealPolicy

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {IRevealPolicy} from "./IRevealPolicy.sol";

contract MyCustomPolicy is IRevealPolicy {
function validate(
uint256 commitment,
uint256 nullifier,
address recipient,
uint256 amount,
address token,
bytes calldata policyParams
) external view override returns (bool) {
// Decode your parameters
(uint256 maxAmount, address requiredToken) =
abi.decode(policyParams, (uint256, address));

// Enforce your rules
if (amount > maxAmount) return false;
if (token != requiredToken) return false;

return true;
}
}

Step 3: Test Within the Gas Cap

Your validate function must execute within 100,000 gas. Common operations and their approximate costs:

OperationGas Cost
abi.decode (2 params)~500
Integer comparison~3
Address comparison~3
keccak256 (32 bytes)~36
ecrecover~3,000
Storage read (SLOAD)~2,100 (cold) / ~100 (warm)
Merkle proof (depth 10)~5,000

Warning: Avoid external calls from within your policy. Since the policy is already called via staticcall, nested calls consume gas from the 100k cap and can fail unpredictably.

Step 4: Deploy and Register

# Deploy the policy contract
forge create src/policies/MyCustomPolicy.sol:MyCustomPolicy --rpc-url $RPC_URL --private-key $KEY

# Register with PolicyRegistry (get the policyId from the event)
cast send $POLICY_REGISTRY "registerPolicy(address)" $MY_POLICY_ADDRESS --rpc-url $RPC_URL --private-key $KEY

Step 5: Use in Commitments

When creating a commitment, set:

const policyId = 42; // Your registered policy ID
const policyParams = ethers.AbiCoder.defaultAbiCoder().encode(
['uint256', 'address'],
[maxAmount, requiredToken]
);
const policyParamsHash = BigInt(
ethers.keccak256(policyParams)
) % BN254_FIELD;

// Include policyId and policyParamsHash in the Poseidon7 commitment
const commitment = poseidon7([
secret, nullifierSecret, tokenId, amount, blinding,
policyId, policyParamsHash
]);

Step 6: Supply Parameters at Reveal

At reveal time, provide the raw policyParams:

await vault.reveal(
proof,
root,
nullifier,
withdrawAmount,
recipient,
changeCommitment,
tokenId,
policyId,
policyParamsHash,
policyParams, // Raw ABI-encoded bytes
quantumSecret
);

The contract will verify keccak256(policyParams) % BN254_FIELD == policyParamsHash and then call your policy's validate function.

Change Commitments and Policy Persistence

When a partial withdrawal creates a change commitment, the policy is carried forward:

Original commitment:
Poseidon7(secret, nullifierSecret, tokenId, 100, blinding1, policyId=5, paramsHash=0xabc)

Partial reveal: withdraw 30

Change commitment:
Poseidon7(secret, nullifierSecret, tokenId, 70, blinding2, policyId=5, paramsHash=0xabc)
^^^^^^^^ ^^^ ^^^^^
new blinding SAME SAME

The ZK circuit enforces this constraint. If the prover attempts to generate a change commitment with a different policyId or policyParamsHash, the proof will be invalid. This makes policies inescapable — once bound, they govern every fragment of the original commitment.

Why Policies Are Inescapable

Consider a scenario: a compliance officer commits 1000 GHOST with a DestinationRestriction policy limiting reveals to KYC-verified addresses. Without policy persistence:

  1. The user partially reveals 1 GHOST to a KYC-verified address (passes policy)
  2. The change commitment (999 GHOST) has no policy
  3. The user reveals 999 GHOST to any address (bypasses policy)

With policy persistence, the change commitment inherits the same DestinationRestriction, and every subsequent reveal must also satisfy the policy. There is no escape hatch.

Policy Composition

For complex requirements, compose multiple conditions within a single policy contract rather than chaining multiple policies:

contract CompositePolicy is IRevealPolicy {
function validate(
uint256 commitment,
uint256 nullifier,
address recipient,
uint256 amount,
address token,
bytes calldata policyParams
) external view override returns (bool) {
(
uint256 notBefore,
uint256 notAfter,
address allowedRecipient,
uint256 maxAmount
) = abi.decode(policyParams, (uint256, uint256, address, uint256));

// Timelock check
if (block.timestamp < notBefore || block.timestamp > notAfter) return false;

// Destination check
if (recipient != allowedRecipient) return false;

// Amount cap
if (amount > maxAmount) return false;

return true;
}
}

Each commitment supports exactly one policyId. For multiple independent conditions, combine them in a single policy contract.