Draft ERC: Chain-Level Protocol Escrow Standard (`ProtocolEscrow`)

Category: EIPs → ERC
Status: Request for Discussion
Author: Louis Liu (@louisliu2048)
Requires: ERC-20


Summary

We propose a minimal standard interface for a shared escrow contract — deployable as chain-level infrastructure or standalone — that allows any on-chain protocol to delegate asset custody without coupling its business logic to fund management.

Protocols register with ProtocolEscrow, users deposit into protocol sub-accounts, and protocols trigger releases after completing their own authorization checks. The escrow layer never inspects how a protocol authorizes a withdrawal. It only enforces that the registered protocol contract made the call and that basic safety invariants hold.


Motivation

Every protocol that holds user assets independently faces three recurring problems:

1. Security surface area. Each protocol’s fund pool is an independent attack target. The majority of large DeFi exploits target custody logic, not business logic. Reentrancy bugs, fee-on-transfer accounting errors, replay vulnerabilities — these are solved problems that should not need to be re-solved per protocol.

2. Compliance burden. In many jurisdictions, holding user funds triggers custodian obligations. A protocol team that delegates custody to independently-governed chain infrastructure can credibly argue it is a software provider, not a financial custodian.

3. Duplicated engineering. Every protocol independently reimplements reentrancy guards, balance-delta accounting, replay protection, and pause mechanisms. This is shared infrastructure that benefits no one by being duplicated.

A subtlety worth stating explicitly: ProtocolEscrow does not remove a protocol’s legitimate business control over its funds. The protocol still defines entirely when a withdrawal is valid, how much can be released, and to whom. What changes is the cost of misbehaviour: a protocol team can no longer act quietly. Every contract change goes through a public timelock; every release is recorded on-chain. Legitimate protocols operate exactly as before. Bad actors must now act visibly or not at all.

ERC-4626 standardized yield vault interfaces. This standard addresses the complementary problem: custody without yield, where the goal is strict asset segregation and permissionless release triggering.


Use Cases

ZK Privacy Transfer Protocols. A ZK privacy protocol’s value is its cryptography, not its custody engineering. With ProtocolEscrow, the protocol contract reduces to: verify ZK proof → call release. Zero custody code. The protocol team also sidesteps custodian classification: the chain’s independent governance holds the funds, not the protocol team.

Cross-Chain Bridges. Bridge lock contracts are the highest-value DeFi attack targets. With ProtocolEscrow, the bridge registers as a protocol; users deposit into its sub-account; relayers call release after destination-chain finality is confirmed. releaseId = keccak256(srcChainId, srcTxHash, asset, recipient, amount) is naturally unique per transfer.

Payment and Invoice Protocols. Pre-funded invoices, milestone payments, decentralized OTC — any conditional payment flow where funds are held pending a verifiable on-chain condition. The condition logic lives in the registered protocol contract; ProtocolEscrow handles asset settlement.

Time-Locked Escrow. Vesting schedules, cliff releases, time-gated payments — all reducible to “check block.timestamp, then call release”. No custom vault needed.

Shared Chain Infrastructure. For chains that deploy ProtocolEscrow as a pre-deployed system contract, every new protocol has audited custody infrastructure on day one. The chain’s total attack surface decreases as protocols migrate from self-managed vaults to the shared escrow layer.


Specification

The key words “MUST”, “MUST NOT”, “SHOULD”, “RECOMMENDED”, and “MAY” are interpreted as described in RFC 2119.

Definitions

Term Definition
ProtocolEscrow The escrow contract implementing this standard.
protocolId A bytes32 identifier uniquely and bidirectionally bound to one protocol contract address.
asset An ERC-20 token address, or address(0) for the native token.
sub-account The logical balance slot (protocolId, asset) maintained by ProtocolEscrow.
releaseId A one-time identifier generated by the protocol for replay protection. Uniqueness is enforced globally across all assets for a given protocolId.
authorization data Protocol-specific data (ZK proof, signature, time condition, etc.) verified by the protocol before calling release. ProtocolEscrow never inspects this data.

Section 1: Core Interface (MANDATORY)

// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.0;

interface IProtocolEscrow {

    // Emitted when a deposit is recorded (amount = actually credited, after fee-on-transfer)
    event Deposited(bytes32 indexed protocolId, address indexed asset, address indexed depositor, uint256 amount);

    // Emitted when a settlement release is executed
    event Released(bytes32 indexed protocolId, address indexed asset, address indexed recipient, uint256 amount, bytes32 releaseId);

    /// @notice Deposit assets into a protocol sub-account.
    /// MUST revert if: protocolId unregistered, deposits paused (if pause implemented),
    /// asset not whitelisted (if whitelist implemented), protocolId=0, amount=0,
    /// ERC-20 with msg.value>0, or native token with msg.value!=amount.
    /// Credits balance using balance-delta method (fee-on-transfer safe).
    /// If hook mode is REQUIRED, calls IEscrowDepositHook.onDeposit after crediting.
    /// MUST be protected against reentrancy.
    function deposit(bytes32 protocolId, address asset, uint256 amount, bytes calldata data) external payable;

    /// @notice Execute a settlement release. Callable only by the registered protocol contract.
    /// MUST revert if: caller not registered contract, to=address(0), amount=0,
    /// releaseId already consumed (global scope), balance insufficient, release paused.
    /// Follows CEI: mark releaseId used → deduct balance → transfer.
    /// ERC-20 transfers MUST use SafeERC20. MUST be nonReentrant.
    function release(bytes32 protocolId, address asset, address to, uint256 amount, bytes32 releaseId, bytes calldata data) external;

    /// @notice Current sub-account balance.
    function escrowBalance(bytes32 protocolId, address asset) external view returns (uint256);

    /// @notice Max depositable amount. Returns 0 if paused or unregistered.
    /// For a registered protocol with an allowed asset, returns >0 when not paused.
    function maxDeposit(bytes32 protocolId, address asset) external view returns (uint256);

    /// @notice Max releasable amount. Returns 0 if release is paused.
    function maxRelease(bytes32 protocolId, address asset) external view returns (uint256);

    /// @notice Faithful dry-run of release(). Returns (true, 0) if it would succeed.
    /// Mirrors release() signature so simulation is complete — caller and to are checked.
    function canRelease(
        address caller, bytes32 protocolId, address asset, address to,
        uint256 amount, bytes32 releaseId, uint256 dataLength
    ) external view returns (bool ok, bytes32 reasonCode);
}

Section 2: Optional — Deposit Hook Interface

A protocol MAY opt into deposit notifications by implementing IEscrowDepositHook with hook mode REQUIRED.

interface IEscrowDepositHook {
    // Called by ProtocolEscrow after crediting the balance.
    // MUST revert if msg.sender != ProtocolEscrow. Revert rolls back the deposit.
    function onDeposit(bytes32 protocolId, address depositor, address asset, uint256 amount, bytes calldata data) external;
}
Mode Behaviour
NONE No callback. Credits balance and emits Deposited.
REQUIRED Calls onDeposit after crediting (CEI). Hook revert rolls back deposit.

Section 3: Optional — Extended Query Interface

interface IProtocolEscrowQueryable {
    function protocolContractOf(bytes32 protocolId) external view returns (address);
    function isProtocolRegistered(bytes32 protocolId) external view returns (bool);
    function isDepositPaused() external view returns (bool);
    function isProtocolDepositPaused(bytes32 protocolId) external view returns (bool);
    function protocolReleasePauseUntil(bytes32 protocolId) external view returns (uint64);
    // Global scope — no asset parameter
    function isReleaseIdUsed(bytes32 protocolId, bytes32 releaseId) external view returns (bool);
    // Returns true if asset accepted; if no whitelist, SHOULD return true for all assets
    function isAssetAllowed(address asset) external view returns (bool);
    // Returns type(uint256).max if no limit enforced
    function maxCallDataBytes() external view returns (uint256);
}

Section 4: Standard Reason Codes

Computed as keccak256(abi.encodePacked("CODE_NAME")) — packed encoding, not abi.encode.

Constant Name Condition
REASON_UNREGISTERED protocolId has no registered contract.
REASON_UNAUTHORIZED caller is not the registered contract for protocolId.
REASON_RELEASE_PAUSED Protocol release is within an active pause window.
REASON_RELEASE_ID_USED releaseId has already been consumed.
REASON_INSUFFICIENT_BALANCE Sub-account balance < amount.
REASON_INVALID_AMOUNT amount is 0.
REASON_INVALID_RECIPIENT to is address(0).
REASON_DATA_TOO_LONG (optional) dataLength exceeds implementation maximum.

Section 5: Key Behavioural Rules

Registration

  • protocolIdprotocolContract is a one-to-one, bidirectional, permanent binding. No unregisterProtocol.
  • Re-registration of an existing protocolId MUST revert.
  • Implementations that support post-registration address changes MUST use a two-phase process: propose → timelock → activate. Immutable deployments are exempt.

Deposit

  • Balance credited using balance-delta method (balanceAfter − balanceBefore). Mandatory for fee-on-transfer tokens.
  • ERC-20 deposit with msg.value > 0 MUST revert (no recovery path for accidentally sent ETH).

Release

  • Both forward (protocolContracts[protocolId] == msg.sender) and reverse (protocolIdsByContract[msg.sender] == protocolId) bindings MUST be checked.
  • CEI order: mark releaseId used → deduct balance → transfer.
  • ProtocolEscrow MUST NOT inspect the protocol’s authorization mechanism.

Release ID anti-replay

  • Uniqueness enforced at (protocolId, releaseId) — globally across all assets.
  • Per-(protocolId, asset, releaseId) scoping is explicitly prohibited: it creates a cross-asset replay surface where the same authorization payload can drain multiple sub-accounts.
  • Recommended construction: keccak256(abi.encode(protocolId, chainId, asset, recipient, amount, nonce)).

Asset whitelist (optional)

  • Removing an asset from the whitelist blocks future deposits only. Existing reserves MUST remain releasable.

Pause semantics (optional)

  • Release pauses MUST be time-bounded (TTL) and expire automatically. Permanent freezing is prohibited.
  • If emergencyGuardian is implemented: cooldown MUST be strictly greater than maxPauseSeconds (equal permits seamless chaining ≡ permanent freeze). Guardian MUST NOT be an EOA. Guardian MUST NOT have any asset-moving capability. Early manual unpause MUST be restricted to governance only.

How It Works

Deposit

User → ProtocolEscrow.deposit(protocolId, USDC, 1000, data)
  ├─ validate: registered, not paused, asset allowed, msg.value==0
  ├─ safeTransferFrom(user, this, 1000)
  ├─ credited = balanceAfter - balanceBefore  ← fee-on-transfer safe
  ├─ reserves[protocolId][USDC] += credited
  ├─ [if REQUIRED] Protocol.onDeposit(protocolId, user, USDC, credited, data)
  └─ emit Deposited(protocolId, USDC, user, credited)

Release

User → Protocol.withdraw(authData, ...)
  ├─ Protocol verifies authorization (ZK proof / signature / timelock / etc.)
  └─ Protocol → ProtocolEscrow.release(protocolId, USDC, user, 1000, releaseId, "")
       ├─ check: caller == protocolContracts[protocolId]
       ├─ check: protocolIdsByContract[caller] == protocolId
       ├─ check: to!=0, amount>0, not paused, releaseId unused, balance>=amount
       ├─ usedReleaseIds[protocolId][releaseId] = true    ← CEI step 1
       ├─ reserves[protocolId][USDC] -= 1000              ← CEI step 2
       ├─ safeTransfer(USDC, user, 1000)                  ← CEI step 3
       └─ emit Released(...) + ReleaseIdConsumed(...)

Rationale

Why two functions? The interface surface is intentionally minimal: accept funds and release funds. All business logic — when to accept, what verification to require, how to track user balances — belongs to the protocol. This boundary means ProtocolEscrow can be audited once and shared by arbitrarily many protocols.

Why authentication-neutral? Unlike designs that hard-code ZK proof verification at the escrow layer, release trusts that the calling registered contract has already completed its own authorization checks. ZK protocols, multi-signature protocols, time-lock protocols, and any future scheme can share the same escrow layer without contract changes.

Why is releaseId global across assets? Per-(protocolId, asset, releaseId) scoping creates a cross-asset replay surface: the same ZK nullifier or signed payload could be submitted once per asset to drain multiple sub-accounts. Global scope eliminates this class of attack entirely. Protocols should include asset in their releaseId pre-image as a second layer of defence.

Why is the deposit hook optional? Requiring onDeposit for all protocols adds an external call (reentrancy surface) that stateless protocols don’t need. Making the hook governance-configured per protocol (NONE / REQUIRED) allows simple escrow protocols to skip the callback while ZK privacy protocols receive real-time commitment updates.

Why does canRelease mirror release exactly? Preview functions are only useful if they accurately predict the real call. Including caller, to, and dataLength prevents false positives where the preview returns true but the actual call reverts on UNAUTHORIZED_CALLER, INVALID_RECIPIENT, or DATA_TOO_LONG.

Why no ForceWithdraw? A chain-level ForceWithdraw would require ProtocolEscrow to understand each protocol’s authorization semantics — who is allowed to force-exit which user. Since authorization logic is deliberately kept in the protocol contract, there is no generalizable force-exit the escrow layer can implement. Protocols that need censorship resistance must ensure their own withdraw is permissionless.


Prior Art

Relation
ERC-4626 Inspired the preview pattern (maxDeposit, maxRelease, canRelease). ERC-4626 solves yield accounting; this standard solves custody without yield.
Tornado Cash / Railgun These protocols each maintain their own fund pool. This standard extracts that layer into shared infrastructure, letting the protocol focus purely on its proof system.
vosa-20 A ZK-based privacy transfer protocol for ERC-20 tokens that directly motivated this standard. vosa-20 requires a shared custody layer to operate without holding user funds itself.
Gnosis Safe Modules Similar philosophy of separating asset custody from authorization logic. Safe Modules operate at account level; ProtocolEscrow operates at protocol level with governance-controlled registration and isolated sub-accounts.
ERC-1155 The (protocolId, asset) sub-account model is conceptually similar to ERC-1155’s multi-token ID space, applied to custody balances rather than token ownership.

Security Considerations

Reentrancy. Both deposit and release MUST be nonReentrant. deposit calls onDeposit (untrusted); release calls safeTransfer (potentially untrusted token).

releaseId cross-asset replay. Eliminated by protocol-global deduplication. A consumed releaseId cannot be replayed for any asset under the same protocol. Per-asset scoping is not conformant with this standard.

Fee-on-transfer tokens. Balance-delta crediting is mandatory. Crediting the caller-provided amount creates phantom balances that eventually cause release reverts.

ERC-20 with msg.value > 0. Must revert. ETH sent alongside an ERC-20 deposit has no recovery path.

emergencyGuardian pause abuse. Cooldown MUST be strictly greater than maxPauseSeconds. Equal values allow seamless back-to-back pauses that constitute a de-facto permanent freeze.

Governance key compromise. A compromised governance key cannot directly transfer user assets. It could register a malicious protocol contract that drains its own sub-account. The specific governance model is deployment-defined; implementations SHOULD use a multisig with a timelock for high-risk operations.

Protocol contract change window. During propose → activate, the old contract remains authorized. Deployments with pause support can freeze releases during this window. Deployments without pause should size the timelock accordingly.

Concentration risk. A vulnerability in ProtocolEscrow affects all registered protocols simultaneously. This is the accepted trade-off: one high-quality audited contract replaces many lower-quality per-protocol vaults.


Test Checklist (Summary)

Tier 1 — Core Conformance (mandatory)

  • deposit/release happy path for ERC-20 and native token
  • Balance credited by delta, not by caller-provided amount
  • Unregistered caller, wrong protocolId, reverse-binding mismatch each revert
  • Duplicate releaseId reverts; same releaseId for different asset also reverts (global scope)
  • amount > balance reverts; protocolId=0 / to=0 / amount=0 each revert
  • ERC-20 with msg.value>0 reverts; native with msg.value!=amount reverts
  • canRelease is faithful: (true,0) iff release would succeed; correct reasonCode for each failure
  • Reentrancy: ERC-20 transfer callback in release does not corrupt state; (if hook implemented) onDeposit re-entry does not corrupt state

Tier 2 — Extended Profile (required if feature is implemented)

  • Hook modes: NONE skips callback; REQUIRED reverts on hook failure; non-ProtocolEscrow caller reverts
  • Pause: global/per-protocol deposit and release pause isolation; TTL auto-expiry
  • Guardian cooldown: second pause within cooldown reverts; guardian cannot unpause
  • Whitelist: non-allowed asset deposit reverts; removed asset release still succeeds
  • Contract change: timelock state machine (before eta, after expiry, concurrent proposals)
  • Governance: unauthorized caller on any privileged function reverts

Backwards Compatibility

This ERC introduces new interfaces and does not modify any existing standard. Existing ERC-20 and native token mechanics are unchanged. Protocols wishing to adopt this standard integrate by implementing the registration and release-triggering flow described above.


Open Questions for Discussion

  1. protocolId derivation standard. Should the ERC mandate keccak256(abi.encode(chainId, name, owner)) or leave it to implementations?

  2. Released vs ReleaseIdConsumed. We specify both — one for financial audit trails, one for replay detection. Is the gas duplication worth it, or should one event carry both roles?

  3. canRelease parameter overhead. caller, to, and dataLength make the dry-run faithful but add calldata cost. Should a lighter canReleaseSimple(protocolId, asset, amount, releaseId) be part of the standard?

  4. Governance interface standardization. The spec leaves governance model entirely implementation-defined. Should the ERC specify a minimal propose / execute interface, or keep it open?

  5. Pre-deploy vs. standalone deployment. Designed with chain-level pre-deployment in mind, but equally valid as a standalone contract. Should the ERC explicitly accommodate both?

  6. Multi-asset batch release. Should an optional batchRelease be part of the standard, or left to protocols?

  7. Protocol disaster recovery requirement. ProtocolEscrow provides no chain-level fallback if a protocol’s withdraw becomes permanently inaccessible. The current permissionless-withdraw admission check only validates the normal path. Should the standard require registered protocols to demonstrate a disaster recovery path (e.g., a documented upgrade plan that preserves user fund access via contract migration)? Three positions:

    • Strict: Require a documented upgrade/recovery plan as a governance prerequisite for registration.
    • Soft: Recommend it as best practice; leave enforcement to each deployment.
    • Agnostic: Out of scope — protocol-level fault tolerance is the protocol’s responsibility.

We look forward to feedback from teams building privacy protocols, cross-chain bridges, payment systems, or any other protocol that currently maintains its own fund pool. The goal is to identify whether this abstraction is the right level, whether the interface has gaps, and whether the security properties are sufficient for production use.

1 Like