Skip to main content

Create a Custom Policy

Policies are smart contracts that enforce rules at reveal time in the Ghost Protocol commit/reveal flow. When a user commits tokens with a policy attached, the policy contract is invoked during the reveal to validate that the reveal meets the policy's constraints — without breaking the zero-knowledge privacy guarantee for compliant users.

How Policies Work

  1. During commit, the user specifies a policyId (the policy contract address) and policyParams (ABI-encoded parameters). These are hashed into the commitment.
  2. During reveal, the zero-knowledge proof demonstrates that the commitment included a specific policyId and policyParamsHash. The CommitRevealVault then calls the policy contract's validateReveal() function.
  3. The policy contract performs pure validation — it checks conditions and either allows or reverts the reveal. Policies cannot write state.

The 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 operation is permitted.
/// @param revealer The address submitting the reveal transaction.
/// @param recipient The address that will receive the revealed tokens.
/// @param token The token contract address being revealed.
/// @param amount The amount of tokens being revealed.
/// @param policyParams ABI-encoded parameters specific to this policy instance.
/// @return valid True if the reveal is permitted, false otherwise.
function validateReveal(
address revealer,
address recipient,
address token,
uint256 amount,
bytes calldata policyParams
) external view returns (bool valid);
}

Constraints

  • View-only: validateReveal must be a view function. It cannot modify state. The CommitRevealVault calls it with staticcall.
  • Gas limit: Policy validation is subject to a 100,000 gas limit. If your policy exceeds this, the reveal reverts. Keep logic simple and avoid loops over unbounded data.
  • Deterministic: The function must return the same result for the same inputs. Do not rely on block-dependent values like block.timestamp for anything other than time-based policies where temporal variance is expected.

Step 1: Implement the Policy

Here is a complete example of a policy that restricts reveals to a maximum amount per transaction:

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

import "./IRevealPolicy.sol";

/// @title MaxAmountPolicy
/// @notice Restricts reveals to a maximum token amount per transaction.
contract MaxAmountPolicy is IRevealPolicy {
/// @notice Validates that the reveal amount does not exceed the maximum.
/// @param policyParams ABI-encoded (uint256 maxAmount).
function validateReveal(
address /* revealer */,
address /* recipient */,
address /* token */,
uint256 amount,
bytes calldata policyParams
) external pure returns (bool valid) {
uint256 maxAmount = abi.decode(policyParams, (uint256));
return amount <= maxAmount;
}
}

Encoding policyParams

Policy parameters are encoded using Solidity's abi.encode. The encoding must match what the policy contract expects to decode:

import { ethers } from "ethers";

// For MaxAmountPolicy: encode a single uint256
const maxAmount = ethers.parseEther("1000"); // 1000 tokens max
const policyParams = ethers.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[maxAmount]
);

For more complex policies with multiple parameters:

// For a policy that takes (address allowedRecipient, uint256 minAmount, uint256 maxAmount)
const policyParams = ethers.AbiCoder.defaultAbiCoder().encode(
["address", "uint256", "uint256"],
["0xRecipientAddress", ethers.parseEther("10"), ethers.parseEther("1000")]
);

The policyParamsHash stored in the commitment is computed as:

const policyParamsHash = ethers.keccak256(policyParams);

This hash is included in the Poseidon commitment, binding the policy parameters to the commitment without revealing them until reveal time.

Step 2: Deploy to Specter

Deploy your policy contract to the Specter chain (Chain ID 5446):

import { ethers } from "ethers";

const provider = new ethers.JsonRpcProvider("https://testnet.specterchain.com");
const signer = new ethers.Wallet(process.env.PRIVATE_KEY, provider);

const MaxAmountPolicy = new ethers.ContractFactory(abi, bytecode, signer);
const policy = await MaxAmountPolicy.deploy();
await policy.waitForDeployment();

const policyAddress = await policy.getAddress();
console.log("Policy deployed at:", policyAddress);

Step 3: Register in PolicyRegistry (Optional)

The PolicyRegistry (0x2DC1641d5A32D6788264690D42710edC843Cb1db) is an on-chain directory of verified policy contracts. Registration is optional but recommended for discoverability and trust:

const registryAbi = [
"function registerPolicy(address policy, string name, string description) external"
];

const registry = new ethers.Contract(
"0x2DC1641d5A32D6788264690D42710edC843Cb1db",
registryAbi,
signer
);

const tx = await registry.registerPolicy(
policyAddress,
"MaxAmountPolicy",
"Restricts reveals to a maximum token amount per transaction."
);
await tx.wait();

Registered policies can be queried by anyone:

const queryAbi = [
"function getPolicy(address policy) view returns (string name, string description, address deployer, bool active)"
];
const registryReader = new ethers.Contract(
"0x2DC1641d5A32D6788264690D42710edC843Cb1db",
queryAbi,
provider
);

const info = await registryReader.getPolicy(policyAddress);
console.log(info.name, info.description);

Step 4: Use the Policy with commitWithPolicy

When committing tokens, specify the policy contract and its parameters:

const vaultAbi = [
"function commitWithPolicy(bytes32 commitment, address token, uint256 amount, address policyId, bytes policyParams) external"
];

const vault = new ethers.Contract(
"0x908aA11Dc9F2e2C3F69892acaDE112e831c0a14a",
vaultAbi,
signer
);

// Encode the policy params
const policyParams = ethers.AbiCoder.defaultAbiCoder().encode(
["uint256"],
[ethers.parseEther("1000")]
);

// The commitment must include the policyId and policyParamsHash
// (computed using Poseidon7 with all fields including policyId and keccak256(policyParams))
const commitment = computeCommitment({
secret,
nullifierSecret,
blinding,
tokenIdHash,
amount,
policyId: policyAddress,
policyParamsHash: ethers.keccak256(policyParams),
});

const tx = await vault.commitWithPolicy(
commitment,
tokenAddress,
amount,
policyAddress,
policyParams
);
await tx.wait();

At reveal time, the CommitRevealVault automatically calls validateReveal() on the specified policy contract. If the policy returns false or reverts, the entire reveal transaction reverts.

Built-In Policies

Specter ships with three built-in policies:

PolicyAddressDescription
TimelockExpiry0xd84D534E94f1eacE9BC5e9Bd90338d574d02B95cEnforces a minimum time delay and optional expiry on reveals. policyParams: (uint256 unlockTime, uint256 expiryTime).
DestinationRestriction0x584F2c7F6da6f25a7bF6A1F3D7F422683Ac52Ef1Restricts reveals to a specific recipient address. policyParams: (address allowedRecipient).
ThresholdWitness0x5814e4755C0D98218ddb752D26dD03feba428c80Requires M-of-N signatures from designated witnesses. policyParams: (uint256 threshold, address[] witnesses).

Complete Example: Time-Locked Commit

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

import "./IRevealPolicy.sol";

/// @title WeekdayOnlyPolicy
/// @notice Only allows reveals on weekdays (Monday through Friday, UTC).
contract WeekdayOnlyPolicy is IRevealPolicy {
function validateReveal(
address,
address,
address,
uint256,
bytes calldata
) external view returns (bool valid) {
// day: 0 = Sunday, 1 = Monday, ..., 6 = Saturday
uint256 day = (block.timestamp / 1 days + 4) % 7;
return day >= 1 && day <= 5;
}
}

Security Considerations

  • Immutability: Once a commitment includes a policyId, it cannot be changed. Choose your policy carefully before committing.
  • Gas awareness: The 100,000 gas limit is strictly enforced. Test your policy's gas consumption with estimateGas before deployment.
  • No external calls: Avoid making external calls from within validateReveal. Reentrancy is not possible (it is a staticcall), but external calls consume gas and may fail unpredictably.
  • Parameter validation: Always validate that policyParams decodes correctly. A malformed encoding will cause abi.decode to revert, which reverts the reveal.