Policy-Aware Precompile Wrapper

mechanism privacy

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

The wrapper composes three pieces. The Executor is the raw, policy-unaware privacy logic (deposit, transfer, withdraw, batch variants); it exposes 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

Each 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 IsSender flag is true (the effective sender must be the policy caller).
  • Both From and To are non-zero 20-byte EVM addresses.
  • Asset is a non-empty denom string.
  • Value is non-nil and non-negative.
  • Selector is 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

For every mutating call the wrapper runs, in order:

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

Both paths enforce contract-scoped PCL policies, but they serve different targets. The PCL Proxy Hook wraps user-deployed upgradeable proxies (Transparent / UUPS) and forwards 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

From a dApp's perspective, the wrapper is invisible except through revert reasons. Anything PCL rejects surfaces as a typed PCL ReasonCode ABI-encoded into the revert data — the same shape documented in pcl-reason-codes. Common examples on privacy calls:

ReasonCodeMeaning
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.
ESC
Type to search