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
- Bound at commit time. The
policyIdandpolicyParamsHashare two of the seven inputs to the Poseidon7 commitment hash. They are inside the ZK circuit. - Tamper-proof. Changing the policy would change the commitment, which would invalidate the Merkle proof. The ZK circuit enforces that the public
policyIdandpolicyParamsHashmatch the committed values. - Inescapable. Change commitments (from partial withdrawals) carry the same
policyIdandpolicyParamsHashforward. You cannot split your way out of a policy. - Stateless enforcement. Policy contracts are called via
staticcall— they cannot modify state. They receive inputs and return a boolean. - Gas-capped. Policy execution is limited to 100,000 gas to prevent denial-of-service via expensive policy logic.
- 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:
| Constraint | Mechanism | Reason |
|---|---|---|
| Read-only | staticcall | Policy cannot modify state, mint tokens, or trigger side effects |
| Gas limit | 100,000 gas cap | Prevents DoS via expensive computation |
| Return type | bool | Simple accept/reject — no partial amounts or redirects |
| Parameters | Verified by hash | Cannot 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:
| Parameter | Type | Description |
|---|---|---|
notBefore | uint256 | Earliest allowed reveal time (Unix timestamp). 0 = no lower bound |
notAfter | uint256 | Latest 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:
| Mode | Parameter Encoding | Description |
|---|---|---|
| Single address | abi.encode(address) (32 bytes) | Only the specified address can receive tokens |
| Merkle allowlist | abi.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:
| Parameter | Type | Description |
|---|---|---|
threshold | uint256 | Minimum number of valid signatures required |
witnesses | address[] | Addresses of authorized witnesses |
signatures | bytes[] | EIP-191 signatures over the nullifier |
How it works:
- At commit time, the
policyParamsencode thethresholdandwitnesseslist. Thesignaturesarray is empty (or a placeholder) — only the hash of the full params matters at commit time. - Before reveal, the revealer contacts M witnesses and asks them to sign the nullifier.
- At reveal time, the revealer submits the signed messages as
policyParams. The hash must still matchpolicyParamsHash.
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:
| Property | Detail |
|---|---|
| Permissionless | Anyone can register a policy contract |
| Informational | The registry is for discoverability, not access control |
| Immutable mappings | Once registered, a policy ID always points to the same contract |
| No upgrades | Policy 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 revealednullifier— the unique spend tagrecipient— who will receive the tokensamount— how many tokens are being withdrawntoken— which token addresspolicyParams— 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:
| Operation | Gas 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:
- The user partially reveals 1 GHOST to a KYC-verified address (passes policy)
- The change commitment (999 GHOST) has no policy
- 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.