Policy-Aware Precompile Wrapper
Every mutating privacy call is gated by the PCL contract-policy lifecycle at the precompile boundary — evaluate-before, execute, evaluate-after, record — with no explicit runOnPcl entrypoint.
The Privacy precompile at 0x100000000000000000000000000000000000000b does not expose its raw executor directly to the EVM. Instead the chain wraps the executor in a policy-aware precompile that owns the native-action snapshot and drives the full PCL contract-policy lifecycle for every mutating call. Each call must produce exactly one contract-scoped PolicyOperation describing the effective sender, recipient, asset, and value; PCL evaluates policies against that operation before execution, again after execution, and then records the operation. View-only reads and gas accounting flow through the same wrapper, so the PCL enforcement path is not something a dApp opts into — it is the only way to reach the privacy surface.
Architecture
flowchart LR
dapp[dApp Contract]:::evm
wrap[PolicyAwarePrecompile<br/>at 0x1000...000b]:::precompile
exec[Privacy Executor<br/>Prepare / ExecutePrepared]:::precompile
pcl[IPcl policies<br/>evaluate + record]:::precompile
outcome[Success or PCL ReasonCode revert]:::evm
dapp -->|IPrivacy call| wrap
wrap -->|1. Prepare| exec
wrap -->|2. EvaluatePolicyBeforeExecution| pcl
wrap -->|3. ExecutePrepared| exec
wrap -->|4. EvaluatePolicyAfterExecution| pcl
wrap -->|5. RecordPolicyAfterExecution| pcl
wrap --> outcome
classDef evm fill:#0096AA,stroke:#0096AA,color:#fff;
classDef precompile fill:#FF8C50,stroke:#FF8C50,color:#fff; The policy-aware precompile owns the native-action snapshot and drives PCL's full contract-policy lifecycle around each privacy call. dApps see only IPrivacy; PCL enforcement is chain-guaranteed.
Three collaborators — Executor, Wrapper, PCLKeeper
Prepare and ExecutePrepared but is deliberately not itself a PrecompiledContract, so it cannot be reached without the wrapper. The PolicyAwarePrecompile is the single vm.PrecompiledContract registered at the privacy address; it owns the native snapshot and drives the lifecycle. The PCLKeeper exposes EvaluatePolicyBeforeExecution, EvaluatePolicyAfterExecution, and RecordPolicyAfterExecution — the wrapper calls all three around every mutating execution. A missing keeper or nil executor causes the call to revert before any state read.The single contract-scoped PolicyOperation invariant
PreparedCall returned by the executor must expose exactly one ContractPolicyOperation(). The wrapper validates this operation before handing it to PCL and rejects the call outright if any of the following are false:- Scope is
PolicyScopeContract(not global). - The contract address in the operation equals the privacy precompile's own address.
- The
IsSenderflag is true (the effective sender must be the policy caller). - Both
FromandToare non-zero 20-byte EVM addresses. Assetis a non-empty denom string.Valueis non-nil and non-negative.Selectoris exactly 4 bytes.
This invariant is what lets contract-scoped PCL policies (e.g.
DENYLIST_POLICY bound to the privacy address) apply to privacy calls without any per-method wiring.The lifecycle around each call
1.
Prepare(ctx, evm, contract, readonly) — parse and validate ABI inputs, produce a PreparedCall.2. Extract and clone the
PolicyOperation; reject the call if the invariant above fails.3.
EvaluatePolicyBeforeExecution(op) — PCL evaluates every applicable global and contract policy. Any violation reverts with a typed PCL ReasonCode.4.
ExecutePrepared(...) — the executor performs the state change (mint/spend commitments, consume nullifiers, emit events).5.
EvaluatePolicyAfterExecution(op) — post-execution invariants (e.g. periodic-volume caps) are re-checked against the updated state.6.
RecordPolicyAfterExecution(op) — the operation is committed to PCL's accounting stores (periodic-volume counters, etc.).Any step's error unwinds the native-action snapshot; nothing partial persists.
// A dApp just calls IPrivacy directly. It does not (and cannot) call PCL first.
// The wrapper is invisible to Solidity — PCL enforcement is guaranteed by the precompile itself.
import { IPrivacy, PRIVACY_CONTRACT, PrivacyWithdrawRequest } from "@maroo-chain/contracts/precompiles/privacy/IPrivacy.sol";
contract WithdrawExample {
function withdrawTo(PrivacyWithdrawRequest calldata req) external returns (bool) {
// Reverts with a PCL ReasonCode (e.g. InDenylist, ExceededPeriodicVolume)
// BEFORE the underlying withdraw executes if any policy denies the call.
// Reverts with a PCL ReasonCode AFTER execution if a post-check trips
// (e.g. cumulative volume exceeds a cap only after this call is applied).
return PRIVACY_CONTRACT.withdraw(req);
}
} Why this differs from the PCL Proxy Hook
principal explicitly through preCall / postCall on the PCL precompile. The policy-aware precompile wrapper described here is internal to Maroo: it wraps the privacy precompile itself and never surfaces preCall / postCall to the caller. dApps interact with IPrivacy normally; PCL enforcement is guaranteed by the chain, not by the caller. A denylist policy bound to the privacy address, for example, applies to every deposit, transfer, withdraw, and batch variant without additional configuration.Failure surface for callers
pcl-reason-codes. Common examples on privacy calls:| ReasonCode | Meaning |
|---|---|
InDenylist(sender) | The effective sender or recipient is on a contract-scoped denylist. |
ExceededPeriodicVolume(maxLimit, value, resetAt) | Post-execution volume check tripped. |
EasAttestationRequired(sender) | An EAS-gated contract policy demands an attestation the caller lacks. |
Decode these client-side with
decodeErrorResult (viem) or the equivalent ABI helper (ethers v6) to give end users an actionable message. Non-PCL failures (invalid proof, spent nullifier, insufficient tree capacity) surface as plain string reverts from the executor.