Web3 & smart contracts
Partial10 minProtocol Logic & State Violations
A protocol permits an action from a state that its own rules should forbid — a withdrawal while locked, a redeem before maturity, a claim before eligibility — often only reachable through a specific sequence of steps.
Classification
- Domain
- Web3 & smart contracts
- ROOTVIEL support
- Partial — Core is built; parts are not yet wired end-to-end.
- Analysis family
- RootRaven
- CWE
- CWE-840, CWE-691
- OWASP
- SC09
Definition
A protocol-logic (or state-machine) violation is not a single mis-written line but a business rule that the contract fails to enforce: the protocol allows an operation from a state in which that operation should be impossible. The individual functions may each look correct; the defect is that the set of reachable states includes one the designers believed unreachable.
This family is deliberately a residual: when a flaw fits a more specialized family (reentrancy, access control, oracle, accounting…), that family owns it. Protocol logic & state covers the business-rule and state-transition violations that belong to no other family — especially those that only emerge from the cumulative effect of several transactions over time.
Context
These bugs live in stateful protocols with phases, lifecycles, time locks, eligibility windows or multi-step flows: vesting and lockups, auctions, lending markets, staking with cooldowns, cross-round games, escrow and settlement.
- ▸A withdrawal permitted while funds are supposed to be locked.
- ▸A redeem accepted before a maturity timestamp.
- ▸An operation executed in an invalid phase (e.g. claiming during the commit phase).
- ▸A reward or airdrop claimed before eligibility, or claimed twice across steps.
Solidity & EVM fundamentals
A contract is a state machine: its storage encodes the current state, and each external function is a transition. Correctness means that for every reachable state, only the intended transitions are enabled. Solidity gives you no built-in notion of “phase” — you encode it in storage (flags, enums, timestamps, balances) and guard transitions with require statements. A missing or wrong guard silently adds an edge to the state machine.
Two properties matter: an invariant (a predicate that must hold in every reachable state, e.g. “locked funds are never withdrawable”) and a precondition (what must be true to take a transition). Logic bugs are invariant violations reachable because a precondition was too weak, in the wrong order, or absent.
Root cause
The root cause is an incomplete model of reachable states: the developer reasoned about the “happy path” and a few obvious misuse cases, but the real transition graph admits a state the guards do not cover. Frequent sources: comparing the wrong variable, using < where <= was meant, checking a stale cached value, ordering checks after effects, or assuming two steps are atomic when an attacker can interleave a third.
How the attack works, step by step
- 1The attacker maps the protocol’s phases and the guards on each transition.
- 2They look for a state the designers assumed unreachable — often reached by doing steps out of the intended order, or across several transactions/blocks.
- 3They drive the contract into that state (e.g. start a withdrawal, then trigger a lock, then complete the withdrawal).
- 4They invoke the operation that the current state should forbid; the weak guard lets it through.
- 5An invariant is broken — value leaves while locked, a position is claimed twice, a phase is skipped — to the attacker’s benefit.
Vulnerable example
A synthetic vesting vault that checks maturity against a value the user can influence by re-ordering calls:
synthetic — illustrates a state/precondition gap// UNSAFE: the "locked" check and the withdrawal are separable, // and unlockAt is read before a later lock extension applies. mapping(address => uint256) public balance; mapping(address => uint256) public unlockAt; bool public paused; function requestWithdraw() external { require(block.timestamp >= unlockAt[msg.sender], "locked"); // checked here pendingWithdraw[msg.sender] = balance[msg.sender]; // ...but acted on later } function completeWithdraw() external { // no re-check of unlockAt or paused: the earlier snapshot is trusted uint256 amt = pendingWithdraw[msg.sender]; pendingWithdraw[msg.sender] = 0; balance[msg.sender] -= amt; payable(msg.sender).transfer(amt); }
Impact
- ▸Loss of funds that should have been locked, escrowed or vested.
- ▸Double-claims or skipped phases that break the protocol’s economic assumptions.
- ▸Availability and fairness failures (acting in a phase meant for someone else).
- ▸Because the damage is cumulative, impact can be large even when each step looks benign.
Detection: static, dynamic, symbolic, fuzzing, formal
Logic bugs resist pattern matching because there is no fixed “bad” shape — the rule being broken is protocol-specific. The strongest tools are those that reason about state: invariant fuzzing (Echidna/Foundry) that asserts a stated property across random multi-call sequences, property-based testing, and symbolic/bounded model checking that searches for a reachable state violating an invariant. Static analysis helps flag missing re-checks and check-after-effect ordering, but cannot know your business rules unless you state them.
How autonomous agents investigate it
An agent models the contract as a state machine, enumerates phases and the guards on each transition, and proposes candidate invariants (“locked funds are never withdrawable”, “a position is claimed at most once”). It then searches for a transaction sequence that reaches a state violating a candidate invariant — the creative, open-ended step. Crucially, a sequence that looks promising is only a hypothesis: it must be executed and the invariant must be observed to break before anything is called a Finding.
How RootRaven analyzes it
- Support level
- Partial — multi-transaction, time-dependent logic is among the harder frontiers; some mechanisms are proven end-to-end, others are declared but not yet operational.
- The truth it decides
- Whether the protocol permitted an action from a state that should forbid it.
RootRaven treats a suspected logic flaw as a lead, not a result. It synthesizes a candidate sequence of transactions, executes it on a disposable local blockchain (or a local fork of real state — never a live network), and a deterministic, AI-free verifier checks whether a declared security property was actually violated in the resulting state. AI only composes the candidate attack; it never decides truth. A sequence that does not break a stated invariant is reported as UNKNOWN within the tested scope — never as “secure”.
Signal vs Finding: a static “this guard looks weak” is a signal; a Finding requires the invariant to break under live execution, with the transaction sequence and resulting state as reproducible evidence.
Mitigation
- ▸State the invariants explicitly, in code and in tests — the properties that must hold in every reachable state.
- ▸Re-check preconditions at the point of effect, not only at the point of request; never trust a snapshot taken in an earlier call.
- ▸Model phases with an explicit enum/state variable and a single guarded transition function, rather than scattered boolean flags.
- ▸Prefer checks-effects-interactions ordering and make irreversible steps atomic where possible.
- ▸Add invariant fuzz tests that run long, random multi-call sequences against the stated properties.
Corrected example
synthetic — re-check the precondition at the point of effectfunction completeWithdraw() external { require(!paused, "paused"); // re-checked now require(block.timestamp >= unlockAt[msg.sender], "locked"); // re-checked now uint256 amt = pendingWithdraw[msg.sender]; pendingWithdraw[msg.sender] = 0; // effects before interaction balance[msg.sender] -= amt; (bool ok, ) = payable(msg.sender).call{value: amt}(""); require(ok, "transfer failed"); }
Why it works: the forbidding conditions are evaluated against current state at the moment value moves, so an interleaved lock/pause cannot be bypassed by a stale snapshot. Limit: it fixes this transition; a full defense states and tests the invariant across the whole lifecycle.
Verifying the fix
Write the invariant as an executable property and fuzz it: a bounded search over random sequences of requestWithdraw / lock / pause / completeWithdraw should never reach a state where locked funds left the contract. Re-run the original exploit sequence and confirm it now reverts. State the result as “the invariant held across the tested sequences and budget”, never as a guarantee of correctness.
Limitations
Logic correctness is undecidable in general; fuzzing and bounded checking explore a finite slice of the state space, so a clean run means “no violation found within scope”, not “no violation exists”. Deep, long-horizon or cross-protocol sequences may lie beyond the tested budget — exactly the frontier this family marks as partial.
References
- [1]Solidity — Security Considerations
- [2]OWASP Smart Contract Top 10
- [3]MITRE CWE-840 (Business Logic Errors), CWE-691 (Insufficient Control Flow Management)
- [4]Foundry Book — invariant testing
- [5]Echidna — property-based fuzzing
References point to public, well-known technical sources for further study.
Related articles
