Skip to main content

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

PropertyValueRationale
Call typestaticcallPrevents state modification; policies are pure validators.
Gas cap100,000Prevents griefing via unbounded computation. Policies must be gas-efficient.
Parameter bindingkeccak256(policyParams) must match stored hashEnsures 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: The keccak256 hash 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:

  1. Verifies keccak256(policyParams) == commitmentPolicyParamsHashes[commitment].
  2. Calls policyId.validateReveal(...) with the decoded parameters.
  3. 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:

OperationApproximate 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:

PolicyAddressDescription
TimelockExpiry0xd84D534E94f1eacE9BC5e9Bd90338d574d02B95cTime-window enforcement: funds can only be revealed within a specified time range.
DestinationRestriction0x584F2c7F6da6f25a7bF6A1F3D7F422683Ac52Ef1Restricts the reveal recipient to a single address or a Merkle allowlist.
ThresholdWitness0x5814e4755C0D98218ddb752D26dD03feba428c80Requires 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.