Pre-auditThree days of senior audit time for $1,500, credited in full toward your Zealynx audit.3 audit days, credited toward your auditSee how →
When Caught Failures Commit Broken State

When Caught Failures Commit Broken State

16 min
On this page▾
A caught external-call failure does not mean the surrounding economic operation failed.
That distinction is the center of this study. Solidity rolls back the failed external call, then gives the caller a choice: revert the complete transaction or continue from the catch block. If the caller continues, payment, ownership, accounting, or lifecycle changes made before the external call can remain committed.[1][2]
The result can be stranger than a simple revert. A purchase can be recorded without its promised reward. A withdrawal can complete without notifying the system that calculates future rewards. A callback can fail silently while the parent workflow reports success. The local exception is handled. The protocol state is not necessarily coherent.
The anchor is the public Nexalo raffle review, where payment and ticket assignment preceded an NXL reward call. When that call failed, the catch path deactivated the product instead of reverting the purchase. The public report records the finding as Critical, fixed, and verified.[3][4] The Nexalo case study contains the engagement narrative. This article removes the client name from the core question:
Which effects must share one commit boundary, and which failures may safely be isolated?
This commit-boundary question complements earlier Zealynx research on transaction-level backing, state-dependent views and settlement, and operator consent boundaries. Those studies examine conservation, economic interpretation, and authority. Here the variable is failure isolation.

Methodology and evidence boundary

This is a mechanism-led comparison, not a prevalence study.
The core records were selected because they expose the semantics or consequences of caught failures:
  1. Solidity documentation defines what try/catch does and does not roll back.[1]
  2. OpenZeppelin's technical explanation makes the caller/callee rollback boundary explicit.[2]
  3. Nexalo F-2025-0002 shows a caught reward failure after purchase effects.[3]
  4. A public Telcoin contest submission shows a caught plugin notification failure after stake withdrawal.[5]
  5. The Merkl report shows asymmetric exception handling around a claim callback.[6]
  6. The Initia report shows a cross-runtime case where nested messages survived a caught EVM revert because state handling diverged from the intended boundary.[7]
The records do not share one severity system. The Telcoin record is a public contest submission, not a final report finding. The Merkl and Initia records involve different execution environments and protocol responsibilities. They are compared by mechanism, not treated as equal evidence of impact.
Four labels are used throughout:
  • Source fact: what a cited source directly reports.
  • Zealynx classification: the mechanism group assigned in this article.
  • Inference: a design conclusion derived across records.
  • Limitation: something the evidence does not establish.
No frequency, exploitation, monetary impact, deployment, or remediation claim is inferred beyond what a source states.

The execution rule: rollback follows call frames, not business intent

Solidity's documentation says that try/catch is available for external calls and contract creation. If execution reaches a catch block, state changes inside the failed external call have been reverted. Execution in the caller can then continue.[1]
OpenZeppelin describes the corresponding boundary from the caller's perspective: changes made inside the failed low-level call are reverted, while changes applied before or after that call are not.[2]
A simplified example makes the asymmetry visible:
1function purchase(uint256 quantity) external {
2 paymentToken.transferFrom(msg.sender, address(this), price(quantity));
3 tickets[msg.sender] += quantity;
4
5 try rewards.distribute(msg.sender, quantity) {
6 emit RewardDistributed(msg.sender, quantity);
7 } catch {
8 rewardsAvailable = false;
9 }
10}
If rewards.distribute() reverts, its internal effects roll back. Unless the catch block or later code reverts, the token transfer, ticket assignment, and rewardsAvailable update remain.
The EVM has done exactly what the code requested. The problem is that business intent often groups the three operations differently:
1intended purchase
2 = payment
3 + ticket ownership
4 + required reward entitlement
5 + reachable settlement state
Solidity does not know which components are required. The protocol must encode that commit boundary itself.

The Nexalo anchor: a purchase survived its required dependency

The public Nexalo finding reports that buyTickets() and buySpecificTickets() accepted stablecoin payment and assigned tickets before calling the NXL reward distribution path. When reward distribution failed, _distributeNXL() caught the failure and called _handleNXLExhaustion(), which deactivated the product.[3]
The source describes the resulting state:
  1. payment had been collected;
  2. tickets had been assigned;
  3. the product became inactive;
  4. the round remained below the ticket threshold required for winner selection;
  5. later purchases failed the active-product check.[3]
Zealynx classifies this as a partial economic commit with a liveness dead end. The partial commit matters because payment and participation state persisted. The dead end matters because deactivation prevented the round from reaching the threshold that triggered VRF winner selection in the reviewed design.[3]
The mechanism is narrower than "try/catch is unsafe." The unsafe combination was:
1required dependent operation
2+ economic effects committed before the call
3+ caught failure
4+ no valid post-failure settlement or refund transition
The report recommended validating reward availability before distribution, and records the issue as fixed and verified.[3] That status does not identify a remediation commit or prove deployment. The public case study preserves that limitation.[4]

Comparative records: four ways the boundary can fail

RecordFailure handlingState that may surviveZealynx classificationEvidence limitation
Nexalo F-2025-0002Reward call failure is caught; product is deactivatedPayment, ticket ownership, product statePartial economic commit plus liveness dead endProvider-authored report; no deployment or exploitation claim [3][4]
Telcoin submission #51Plugin notification failure is caught after withdrawalReduced stake while plugin accounting may remain staleRequired observer skippedPublic submission is labelled excluded/non-reward in GitHub, not a final accepted finding [5]
Merkl reportGenuine callback failures are swallowed, while some success-handler failures remain uncaughtClaim workflow can continue without the recipient callback succeedingAsymmetric callback contractSource report establishes its own finding, not equivalence to a purchase flow [6]
Initia reportA nested message survives an EVM child revert caught by the parentCross-runtime queued message executionRollback-domain mismatchRuntime-specific behavior, not ordinary Solidity semantics [7]

1. Partial economic commit

Nexalo is the clearest form. A user-facing economic action crosses its intended commit point before a required dependency succeeds.
The critical question is not whether the external call is technically optional. It is whether the protocol remains economically truthful when that call fails. If the purchase price includes a reward entitlement, or the purchase creates participation in a round that must later settle, then preserving only some effects may create a state the product never intended to sell.

2. Required observer skipped

Telcoin submission #51 describes withdrawals that notify plugins of stake changes. The reported code caught notification failures and allowed withdrawal to continue. The submission argues that a plugin could keep rewarding stake that had already been withdrawn.[5]
Zealynx classifies this as a required observer skipped pattern. The observer does not own the withdrawn stake, but it owns derived accounting that must remain synchronized with stake state.
The public GitHub record labels the submission excluded and non-reward.[5] It is therefore not used here as proof of a confirmed Telcoin vulnerability. It is useful as a falsifiable design example: if a subsystem's accounting must track a primary balance, silently skipping its update creates a synchronization obligation somewhere else.
A safe design has to choose one of three explicit models:
  1. notification is required, so failure reverts the withdrawal;
  2. notification is optional, so stale observer state cannot grant value or authority;
  3. notification is deferred, so a durable retry record and reconciliation invariant exist.
Catching the error without choosing a model is not graceful degradation. It is unspecified state.

3. Asymmetric callback contract

The Merkl report describes a claim callback wrapped in try/catch. According to the report, genuine external-call failures were swallowed by an empty catch, while an unexpected return value checked in the success handler could trigger a revert that the catch did not intercept.[6]
This produces an asymmetric interface:
1callee reverts -> caller may continue
2callee returns malformed value -> caller reverts
3callee returns expected value -> caller continues
That ordering may be intentional, but it must be specified. Otherwise, two kinds of callback failure with similar business meaning can have opposite transaction outcomes.
Zealynx classifies this as an asymmetric callback contract. The lesson is not that all callback failures should revert. Hooks are often intentionally isolated to preserve availability. The lesson is that the protocol must define which callback outcomes are mandatory, which are advisory, and which state is authoritative when the callback does not complete.

4. Rollback-domain mismatch

The Initia report covers a more unusual boundary. It reports that nested Cosmos SDK messages dispatched through an EVM execution path were not removed when an external Solidity child reverted inside a parent try/catch. The child EVM call failed, but the nested messages were still executed.[7]
Zealynx classifies this as a rollback-domain mismatch. The caller may believe it isolated a failed child operation, while the integrated runtime has already externalized an effect beyond the rollback journal that the catch statement controls.
This is not ordinary Solidity behavior. It is valuable precisely because it shows the limit of language-level reasoning. In a composed runtime, bridge, account abstraction layer, or precompile, developers must ask:
1What exactly is journaled?
2Which effects are reverted with this call frame?
3Which effects have crossed into another execution domain?
A transaction can be atomic inside one machine and non-atomic across the system that embeds it.

The commit-boundary model

A multi-step operation can be modeled with four sets:
1P = preconditions that must hold before economic effects
2E = primary economic effects
3D = dependent effects required for the product promise
4R = recovery transitions available after dependency failure
A safe transition needs one of two structures.

Structure A: one atomic commit

1validate P
2execute E
3execute D
4if any required step fails: revert E and D
This is appropriate when the dependent effect is part of the same user promise and no truthful partial outcome exists.

Structure B: explicit durable partial state

1validate P
2execute E
3record pending D
4if D fails:
5 preserve a retry, refund, cancel, or alternate-settlement transition in R
This is appropriate when asynchronous or failure-isolated execution is necessary. The partial state is not accidental. It is a named lifecycle state with ownership, deadlines, and recovery.
The dangerous structure is:
1execute E
2attempt D
3catch failure
4continue without proving R
That structure converts a temporary dependency failure into protocol state. Whether it is exploitable is a separate question. It is already a state-machine design decision.

Why Checks-Effects-Interactions is not enough

The Checks-Effects-Interactions pattern protects against a major class of reentrancy failures by placing state effects before external interactions. That ordering can be correct and still produce a partial economic commit.
Consider the tension:
  • Updating state before an external call can prevent reentrant use of stale state.
  • Preserving that state after a caught external-call failure can violate operation atomicity.
The solution is not to abandon CEI. It is to define the failure semantics around it.
If the interaction is required, let its failure revert the complete operation, or explicitly revert in the catch block. If the interaction is optional, ensure the pre-interaction state update remains valid without it. If it is asynchronous, commit a pending state rather than pretending the operation is complete.
CEI answers an ordering question. Commit-boundary design answers an outcome question.

Tests that expose broken commit boundaries

Unit tests that only cover successful dependencies will miss this family. The dependency must be forced to fail after every meaningful prefix of the operation.

1. Snapshot all economic state

Before the call, record:
  • payer and contract balances;
  • ownership or participation records;
  • reward and fee accounting;
  • lifecycle status;
  • settlement eligibility;
  • retry, cancellation, and refund availability.
After a forced failure, compare the complete state, not only the external call result.

2. Test the first unavailable unit

Finite resources fail at boundaries. Test zero capacity, one unit below the requirement, exact capacity, and one unit above it. Nexalo's source centers on reward exhaustion, making this boundary especially relevant.[3]

3. Replace every dependency with a reverting double

A token, reward contract, hook, oracle, bridge adapter, or callback can revert for reasons beyond the expected business condition. Solidity's documentation warns that a failure can occur deeper in the call chain or because of gas, and that the reported reason should not automatically be trusted as coming from the direct callee.[1]
The test should ask whether every failure class leads to an allowed final state.

4. Assert reachable recovery

If the transaction intentionally succeeds after dependency failure, prove that at least one recovery transition remains reachable:
1pending -> retry -> complete
2pending -> refund -> cancelled
3pending -> alternate settlement -> complete
A nominal recovery function is not enough. Test permissions, deadlines, depleted dependencies, paused states, and repeated failure.

5. Test derived systems

If plugins, reward indices, offchain observers, or destination runtimes consume the state transition, verify synchronization under failure. The primary contract can look coherent while a required observer remains stale.

6. Use state-machine properties

A useful property is:
1for every successful top-level transaction:
2 final state is either
3 a complete operation,
4 the unchanged pre-operation state, or
5 a named recoverable state with a reachable exit
This can be fuzzed across dependency failures and operation ordering. The state machine is the right unit because liveness failures often appear only several transitions after the caught exception.

Design review questions

Before approving a try/catch around an economically meaningful call, ask:
  1. What exact callee effects roll back?
  2. What caller effects occurred before the call?
  3. Which of those effects remain valid if the callee fails?
  4. Is the dependency required, advisory, or deferred?
  5. Does the catch block create a named lifecycle state or an accidental one?
  6. Can every persisted partial state settle, retry, cancel, or refund?
  7. Can a user trigger the failure deliberately?
  8. Can a funded or active protocol reach the same state through ordinary exhaustion or downtime?
  9. Do observers and integrated runtimes share the same rollback boundary?
  10. Does the top-level event or return value distinguish complete, pending, and degraded outcomes?
The uncomfortable answer is sometimes that the catch block should not exist. A revert can be the safer product behavior when the protocol cannot honestly fulfill a partial operation.

What this research does not establish

This comparison does not estimate how common caught-failure bugs are.
It does not claim that the Nexalo issue was exploited or deployed. The public record reports a reviewed mechanism and a fixed-and-verified status, with no final remediation commit identified.[3][4]
It does not treat the Telcoin submission as an accepted finding. Its GitHub labels are preserved.[5]
It does not claim that Merkl, Initia, Telcoin, and Nexalo had equivalent severity, likelihood, or user impact. Their architectures and evidence records differ.
It also does not argue that try/catch is generally dangerous. Failure isolation is necessary in many protocols. The narrow conclusion is that isolation must match the economic and runtime boundary of the operation.

The durable rule: a caught failure needs a valid final state

Exception handling is state-transition design.
The failed callee rolls back. The caller does not automatically forget what it already changed. That gap is where a locally handled error can become a persistent protocol condition.[1][2]
Nexalo shows payment and ticket ownership surviving a required reward failure before the product entered a dead end.[3] The Telcoin submission illustrates stale dependent accounting after a caught notification failure.[5] Merkl shows that callback outcomes can be handled asymmetrically.[6] Initia shows that composed runtimes can disagree about what the rollback domain contains.[7]
The reusable rule is precise:
After a caught failure, every surviving effect must belong to a complete or explicitly recoverable protocol state.
If the team cannot name that state, identify who owns it, and prove its exit path, the exception has not been handled. It has only been prevented from reverting.

FAQ

1. Does Solidity try/catch roll back the whole transaction?
No. The failed external call's state changes are reverted, but the caller can continue in a catch block. State changes made by the caller before or after that external call can remain unless the top-level transaction later reverts.[1][2]
2. When should a catch block revert?
A catch block should usually revert when the failed dependency is required for the same economic promise and no valid partial or recoverable state exists. The correct choice depends on the protocol's specified state machine, not on availability alone.
3. Is this the same as reentrancy?
No. Reentrancy concerns control returning before state is safely synchronized. This study concerns state that survives after a caught failure. The two can interact, but they are distinct. See reentrancy attack and Checks-Effects-Interactions.
4. Can a protocol safely continue after an external call fails?
Yes, if the external effect is genuinely optional or the protocol commits a named pending state with a tested retry, cancellation, refund, or alternate settlement path. Continuing is unsafe when it leaves an accidental partial state.
5. How should caught failures be tested?
Force each dependency to revert after every meaningful operation prefix. Compare balances, ownership, accounting, lifecycle status, settlement reachability, and recovery paths across the complete transaction.

Glossary

TermDefinition
TransactionOne top-level state transition whose successful execution commits EVM state changes.
State MachineA model of allowed protocol states and the transitions between them.
Checks-Effects-InteractionsA Solidity ordering pattern that validates, updates state, then performs external interactions.
Reentrancy AttackRecursive control flow that re-enters a contract before an earlier operation is safely resolved.

Sources

[1] https://docs.soliditylang.org/en/latest/control-structures.html - Solidity documentation, control structures and try/catch semantics [2] https://forum.openzeppelin.com/t/a-brief-analysis-of-the-new-try-catch-functionality-in-solidity-0-6/2564 - OpenZeppelin Forum, technical analysis of Solidity try/catch [3] https://zealynx.io/audits/nexalo/raffle-protocol-q4-2025/findings/F-2025-0002 - Zealynx, Nexalo finding F-2025-0002 [4] https://raw.githubusercontent.com/ZealynxSecurity/audits/main/web3/2025-12-zealynx-nexalo.pdf - Zealynx, Nexalo Smart Contract Audit [5] https://github.com/sherlock-audit/2023-02-telcoin-judging/issues/51 - Sherlock public contest submission #51, Telcoin [6] https://code4rena.com/reports/2025-11-merkl - Code4rena, Merkl findings report [7] https://code4rena.com/reports/2025-02-initia-cosmos - Code4rena, Initia Cosmos findings report [8] https://www.certik.com/blog/auditing-with-finite-state-machines-a-complementary-methodology - CertiK, finite-state-machine audit methodology

Working auditors in your corner, all year

Zealynx Insiders: weekly live sessions, 1:1 advisory, pair-auditing, and Krait runs on your code, from an audit firm that publishes its reports.

Carlos (Bloqarl)
Written by

Founder at Zealynx

Keep reading
When Operator Approval Stops Meaning Consent
ResearchOct 15

When Operator Approval Stops Meaning Consent

18 min read
ERC-20 Transfers Need Three Proofs, Not One
ResearchOct 12

ERC-20 Transfers Need Three Proofs, Not One

16 min read
When a Safe View Becomes an Unsafe Settlement Input
ResearchOct 8

When a Safe View Becomes an Unsafe Settlement Input

14 min read