State Machine Pattern

Difficulty: Intermediate

The state machine pattern is a powerful way to model contracts that progress through a defined series of stages or phases. Many real-world processes naturally follow a sequence: an auction goes from Open to Ended to Settled, an order moves from Placed to Shipped to Delivered, a crowdfunding campaign transitions from Active to Successful or Failed. By explicitly encoding these states and their valid transitions, you make the contract's behavior predictable, auditable, and resistant to invalid operations.

In Solidity, state machines are typically implemented using an `enum` to define the possible states and a state variable to track the current state. Modifiers enforce that functions can only be called when the contract is in a specific state. Transition functions move the contract from one state to another, and these transitions can include validation logic, side effects, and access control checks.

A well-designed state machine makes the contract self-documenting. By looking at the enum and the transition functions, any developer or auditor can understand the contract's lifecycle without reading every line of code. This is especially valuable in complex protocols where the interaction between multiple parties (buyers, sellers, arbitrators) depends on the current phase of a transaction.

Time-based transitions add another dimension to state machines. Some state changes should happen automatically after a deadline, like ending an auction after a fixed duration. In Solidity, you cannot have automatic state changes since the EVM only executes code in response to transactions. Instead, you check the time condition at the beginning of relevant functions and update the state accordingly. External keepers or bots can also trigger these transitions.

Common pitfalls include forgetting to handle edge cases during transitions (such as what happens to locked funds when a process fails), not emitting events on state changes (which makes off-chain tracking difficult), and creating states from which there is no exit (deadlock states). Always ensure that every state has a valid transition path, and that funds can be recovered even if the process terminates unexpectedly.

Code examples

Auction State Machine

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract AuctionStateMachine {
    enum AuctionState {
        Created,     // Auction exists but hasn't started
        Active,      // Accepting bids
        Ended,       // Bidding is over, awaiting settlement
        Settled,     // Winner paid, seller received funds
        Cancelled    // Auction cancelled before ending
    }

    struct Auction {
        address seller;
        string item;
        uint256 startPrice;
        uint256 endTime;
        address highestBidder;
        uint256 highestBid;
        AuctionState state;
    }

    mapping(uint256 => Auction) public auctions;
    uint256 public auctionCount;

    // Pending withdrawals for outbid bidders
    mapping(uint256 => mapping(address => uint256)) public pendingReturns;

    event AuctionCreated(uint256 indexed auctionId, address indexed seller, string item);
    event AuctionStarted(uint256 indexed auctionId, uint256 endTime);
    event BidPlaced(uint256 indexed auctionId, address indexed bidder, uint256 amount);
    event AuctionEnded(uint256 indexed auctionId, address winner, uint256 amount);
    event AuctionSettled(uint256 indexed auctionId);
    event AuctionCancelled(uint256 indexed auctionId);

    modifier inState(uint256 auctionId, AuctionState expected) {
        require(auctions[auctionId].state == expected, "Invalid auction state");
        _;
    }

    modifier onlySeller(uint256 auctionId) {
        require(msg.sender == auctions[auctionId].seller, "Not seller");
        _;
    }

    function createAuction(string memory _item, uint256 _startPrice)
        external
        returns (uint256)
    {
        uint256 id = auctionCount++;
        auctions[id] = Auction({
            seller: msg.sender,
            item: _item,
            startPrice: _startPrice,
            endTime: 0,
            highestBidder: address(0),
            highestBid: 0,
            state: AuctionState.Created
        });
        emit AuctionCreated(id, msg.sender, _item);
        return id;
    }

    function startAuction(uint256 auctionId, uint256 duration)
        external
        onlySeller(auctionId)
        inState(auctionId, AuctionState.Created)
    {
        auctions[auctionId].endTime = block.timestamp + duration;
        auctions[auctionId].state = AuctionState.Active;
        emit AuctionStarted(auctionId, auctions[auctionId].endTime);
    }

    function bid(uint256 auctionId)
        external
        payable
        inState(auctionId, AuctionState.Active)
    {
        Auction storage auction = auctions[auctionId];
        require(block.timestamp < auction.endTime, "Auction expired");
        require(msg.value > auction.highestBid, "Bid too low");
        require(msg.value >= auction.startPrice, "Below start price");

        // Refund previous highest bidder
        if (auction.highestBidder != address(0)) {
            pendingReturns[auctionId][auction.highestBidder] += auction.highestBid;
        }

        auction.highestBidder = msg.sender;
        auction.highestBid = msg.value;
        emit BidPlaced(auctionId, msg.sender, msg.value);
    }

    function endAuction(uint256 auctionId)
        external
        inState(auctionId, AuctionState.Active)
    {
        Auction storage auction = auctions[auctionId];
        require(block.timestamp >= auction.endTime, "Auction not yet ended");

        auction.state = AuctionState.Ended;
        emit AuctionEnded(auctionId, auction.highestBidder, auction.highestBid);
    }

    function settle(uint256 auctionId)
        external
        onlySeller(auctionId)
        inState(auctionId, AuctionState.Ended)
    {
        Auction storage auction = auctions[auctionId];
        auction.state = AuctionState.Settled;

        if (auction.highestBidder != address(0)) {
            payable(auction.seller).transfer(auction.highestBid);
        }
        emit AuctionSettled(auctionId);
    }

    function cancelAuction(uint256 auctionId)
        external
        onlySeller(auctionId)
        inState(auctionId, AuctionState.Created)
    {
        auctions[auctionId].state = AuctionState.Cancelled;
        emit AuctionCancelled(auctionId);
    }

    function withdrawPending(uint256 auctionId) external {
        uint256 amount = pendingReturns[auctionId][msg.sender];
        require(amount > 0, "Nothing to withdraw");
        pendingReturns[auctionId][msg.sender] = 0;
        payable(msg.sender).transfer(amount);
    }
}

This auction contract demonstrates a full state machine lifecycle: Created -> Active -> Ended -> Settled, with a Cancelled branch. Each transition is enforced by the inState modifier, and only valid transitions are exposed as functions.

Key points

Concepts covered

State, Transition, enum, Finite State