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:
-
Full mapping: README Security Considerations
-
Exploit tests: README Test Cases
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
getPolicywithout 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.