Policy System Overview
Specter's policy system allows depositors to attach programmable constraints to their commitments. These constraints are enforced at reveal (withdrawal) time, enabling features like timelocks, destination restrictions, and multi-signature requirements -- all without breaking the privacy guarantees of the ZK proof system.
IRevealPolicy Interface
Every policy contract must implement the IRevealPolicy interface:
interface IRevealPolicy {
/// @notice Validates whether a reveal operation should be permitted.
/// @param commitment The original commitment being revealed.
/// @param nullifier The nullifier derived from the commitment.
/// @param recipient The address receiving the withdrawn funds.
/// @param amount The amount being withdrawn.
/// @param token The token address (address(0) for native GHOST).
/// @param policyParams ABI-encoded parameters specific to this policy.
/// @return valid True if the reveal satisfies the policy constraints.
function validateReveal(
bytes32 commitment,
bytes32 nullifier,
address recipient,
uint256 amount,
address token,
bytes calldata policyParams
) external view returns (bool valid);
}
Key constraint: The validateReveal function must be a view function. The CommitRevealVault invokes it via staticcall, meaning the policy cannot modify state. This prevents policies from being used as attack vectors for reentrancy or state manipulation.
Enforcement Mechanism
During CommitRevealVault.reveal(), if a policy is attached to the commitment, the vault executes:
(bool success, bytes memory result) = policyAddress.staticcall{gas: 100_000}(
abi.encodeWithSelector(
IRevealPolicy.validateReveal.selector,
commitment,
nullifier,
recipient,
amount,
token,
policyParams
)
);
require(success && abi.decode(result, (bool)), "Policy validation failed");
Enforcement Properties
| Property | Value | Rationale |
|---|---|---|
| Call type | staticcall | Prevents state modification; policies are pure validators. |
| Gas cap | 100,000 | Prevents griefing via unbounded computation. Policies must be gas-efficient. |
| Parameter binding | keccak256(policyParams) must match stored hash | Ensures the revealer provides the exact parameters committed to at deposit time. |
Flow
Policy Lifecycle
1. Commit Phase (Depositor Sets Policy)
When committing funds, the depositor specifies:
policyId: The address of the policy contract.policyParamsHash: Thekeccak256hash of the parameters that will govern the reveal.
// Example: Timelock policy requiring reveal between two timestamps
bytes memory params = abi.encode(lockUntil, expiresAt);
bytes32 paramsHash = keccak256(params);
vault.commitWithPolicy(token, amount, commitment, qCommitment, timelockAddress, paramsHash);
The actual params are not stored on-chain -- only their hash is. This preserves privacy: observers cannot determine the policy parameters by inspecting the commitment transaction.
2. Reveal Phase (Policy Enforced)
The revealer must provide the original policyParams. The vault:
- Verifies
keccak256(policyParams) == commitmentPolicyParamsHashes[commitment]. - Calls
policyId.validateReveal(...)with the decoded parameters. - Requires the call to succeed and return
true.
Building a Custom Policy
To create a custom policy:
Step 1: Implement the Interface
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IRevealPolicy} from "./IRevealPolicy.sol";
contract MyCustomPolicy is IRevealPolicy {
function validateReveal(
bytes32 commitment,
bytes32 nullifier,
address recipient,
uint256 amount,
address token,
bytes calldata policyParams
) external view override returns (bool valid) {
// Decode your custom parameters
(uint256 minAmount, address requiredToken) = abi.decode(
policyParams,
(uint256, address)
);
// Enforce constraints
if (amount < minAmount) return false;
if (token != requiredToken) return false;
return true;
}
}
Step 2: Gas Budget
Your validateReveal function must execute within 100,000 gas. Guideline budgets:
| Operation | Approximate Gas |
|---|---|
| ABI decode (2 params) | ~500 |
| Storage read (SLOAD) | ~2,100 (cold) / ~100 (warm) |
| Keccak256 (32 bytes) | ~36 |
| ECDSA ecrecover | ~3,000 |
| Comparison operations | ~3 each |
Since the function is called via staticcall, you cannot read storage that was written in the same transaction. All reads must reference pre-existing state.
Step 3: Deploy and Register
// Deploy the policy
MyCustomPolicy policy = new MyCustomPolicy();
// Optionally register in PolicyRegistry for discoverability
PolicyRegistry(registryAddress).register(address(policy));
Step 4: Use with Commitments
bytes memory params = abi.encode(minAmount, requiredToken);
bytes32 paramsHash = keccak256(params);
vault.commitWithPolicy(token, amount, commitment, qCommitment, address(policy), paramsHash);
Built-in Policies
The Specter protocol ships with three built-in policies:
| Policy | Address | Description |
|---|---|---|
| TimelockExpiry | 0xd84D534E94f1eacE9BC5e9Bd90338d574d02B95c | Time-window enforcement: funds can only be revealed within a specified time range. |
| DestinationRestriction | 0x584F2c7F6da6f25a7bF6A1F3D7F422683Ac52Ef1 | Restricts the reveal recipient to a single address or a Merkle allowlist. |
| ThresholdWitness | 0x5814e4755C0D98218ddb752D26dD03feba428c80 | Requires M-of-N witness signatures to authorize a reveal. |
Security Considerations
- staticcall isolation: Policies cannot modify state, emit events, or transfer tokens. They can only read existing state and return a boolean.
- Gas cap protection: The 100,000 gas limit prevents denial-of-service attacks where a malicious policy consumes unbounded gas.
- Parameter commitment: Policy parameters are committed at deposit time via their hash. The revealer cannot alter the parameters; they must provide the exact bytes that hash to the stored value.
- No upgrade path: Once a commitment is made with a policy, that policy is immutable for that commitment. If a policy contract is upgraded or destroyed, commitments bound to it may become unredeemable. Use upgradeable proxies cautiously.