[Draft ERC] PhaseGuard: Standard Interface for Contract Lifecycle State

Abstract

A standard interface for smart contracts that manage their lifecycle through a finite state machine. A conforming contract exposes its current phase, a per-phase access-policy bitmask, and a transition-legality check through four read functions and one event. All guarded entry points share a single 6-phase transition matrix, so lifecycle protection is applied at the phase level rather than per-function.

Motivation

Smart contract lifecycle protection is typically assembled from independent mechanisms: reentrancy guards, pause switches, and initialization guards. Each must be applied correctly to every relevant entry point, and nothing links them or enforces consistency across the contract’s public surface.

That per-function approach keeps producing the same exploit classes: reentrancy (including read-only and cross-function variants), incomplete pause coverage, and uninitialized state exposure. See Security Considerations for a detailed mapping of each class to the phase model.

Existing patterns address individual concerns, but expose only isolated, implementation-specific signals. A contract may expose paused(), but there is no standard way for an external caller to query bootstrap state, in-flight mutation, maintenance mode, terminal finalization, or transition legality.

A standard interface would let composing contracts check a dependency’s lifecycle phase before routing funds or executing a governance action. A contract could avoid calling a dependency while it is MUTATING, a timelock could verify a target is in READY before executing a queued proposal, and a monitoring system could watch for phase changes across any conforming contract without per-protocol adapters.

Interface

interface IPhaseGuard /* is ERC165 */ {

    event PhaseTransition(uint8 indexed fromPhase, uint8 indexed toPhase);

    function phase() external view returns (uint8);
    function getPolicy(uint8 phase_) external pure returns (uint8);
    function isTransitionAllowed(uint8 from, uint8 to) external pure returns (bool);
    function isStable(uint8 phase_) external pure returns (bool);
}

Interface ID: 0x5ae3f743

Phases

ID Phase Type Description
0 UNINITIALIZED Unstable Not initialized. Transitions to READY in the same transaction as deployment.
1 READY Stable Normal operating state. User entry, admin entry, and views are all permitted.
2 MUTATING Unstable Temporary execution state. All guarded entry points and guarded views are blocked.
3 FINALIZED Stable Terminal state. No further transitions. Views only.
4 PAUSED Stable Emergency stop. User entry is blocked. Admin entry and views remain permitted.
5 MAINTENANCE Stable Admin-only operating window. User entry is blocked. Admin entry and views remain permitted.

Core invariant: the contract must return to a stable phase (READY, FINALIZED, PAUSED, or MAINTENANCE) before any guarded function completes.

MUTATING is entered and exited internally by the guard mechanism. It has no forward transitions in the transition matrix and does not persist as the global phase when a guarded function returns.

UNINITIALIZED is the default state at deployment. If bootstrap does not complete, all guarded entry remains blocked.

Transition matrix

isTransitionAllowed returns values consistent with:

From / To UNINITIALIZED READY MUTATING FINALIZED PAUSED MAINTENANCE
UNINITIALIZED - YES NO NO NO NO
READY NO - YES YES YES YES
MUTATING NO NO - NO NO NO
FINALIZED NO NO NO - NO NO
PAUSED NO YES NO YES - YES
MAINTENANCE NO YES YES NO NO -

FINALIZED is a terminal state. Once entered, no transitions are allowed.

PAUSED and MAINTENANCE share the same policy bits but differ in the transition matrix: MAINTENANCE → MUTATING is allowed, PAUSED → MUTATING is not. PAUSED is a stop state while MAINTENANCE is an admin-only operating state.

Policy bitmask

getPolicy returns a uint8. Bits 0–2:

  • bit 0: ALLOW_USER — non-admin callers may enter guarded state-changing functions
  • bit 1: ALLOW_ADMIN — admin callers may enter guarded state-changing functions
  • bit 2: ALLOW_VIEWS — callers may enter guarded view functions

Bits 3-7 are reserved for future use.

Bit Flag / Phase UNINITIALIZED READY MUTATING FINALIZED PAUSED MAINTENANCE
ALLOW_USER 0 1 0 0 0 0
ALLOW_ADMIN 0 1 0 0 1 1
ALLOW_VIEWS 0 1 0 1 1 1

Both the transition matrix and the policy matrix are fixed by the spec.

Covered exploits

The model is meant to cover five recurring exploit classes:

  • regular, read-only, and cross-function reentrancy via MUTATING

  • incomplete pause coverage via PAUSED

  • uninitialized state exposure via UNINITIALIZED

UNINITIALIZED provides fail-closed entry blocking, but it is not a replacement for proxy initialization helpers like OpenZeppelin Initializable.

The full exploit mapping table and exploit tests are in the README:

Reference implementation

Full spec, implementation, tests, gas benchmarks, integration guide, and an ERC-4626 vault example:

Questions

A few design decisions I’d like feedback on:

  • The spec locks in 6 phases and a fixed transition matrix. An alternative would be letting deployers define their own phases, but that breaks composability as callers can’t interpret phase() without knowing the contract’s custom schema. Curious if anyone sees it differently.

  • The bitmask per phase (policy matrix) is also set by the spec, not per deployment. A contract-defined policy would be more flexible, but an external caller couldn’t trust getPolicy without also trusting the implementation. Open to arguments here.

  • The interface is four view functions and one event. If any of these feel redundant or if something is missing, I’d like to hear it.

  • The guard adds ~16–35k gas per guarded call, compared to ~2.7–7.2k for a standalone nonReentrant. The scope is wider though (reentrancy including read-only + pause + lifecycle). Do you find the tradeoff reasonable or should I work on solutions to cut cost in the reference implementation?

Contributions, alternative approaches, and counterarguments are all welcome.

Full spec, rationale, and tests are in the repo README.