Exit Right Consumed-State Binding
- Name: Exit right consumed-state binding
- Category: payout reference / redemption-branch unification / emergency-path discipline / consumed-state checklist
- Summary: Healthy payout systems keep one redeemable right bound to one consumed-state object no matter which exit path spends it. Ordinary withdraw, redeem, unlock, refund, cancel, migration, timeout-refund, or emergency-exit branches may differ in operational details, but they should all converge on the same answer to one question: has this exact entitlement already been spent? When one branch transfers value without consuming that shared right, the system has already created a replay surface even if every other branch looks locally correct.
- Why this note exists:
- Many duplicate-claim incidents are not really about
batch arrays are dangerousin the abstract. They are about branch asymmetry: one path pays first, another path still treats the same lock, share balance, or claim record as fresh. Emergencyandmigrationcode is especially dangerous here because teams often treat it as an operational shortcut instead of as another redemption path that must preserve the same economic identity.- The analytical win is to ask
what exact entitlement object should every payout branch consume?before getting lost in function-level differences likewithdrawAll()versusemergencyBurn()orunlock after expiryversusunlock before expiry.
- Many duplicate-claim incidents are not really about
The five joints a healthy exit system should keep fused
-
Canonical entitlement identity
- The system should have one exact object that represents the spendable right: a vault share balance, lock record, withdrawal ticket, Merkle leaf, epoch claim, migration position, or bridge message.
- Different code paths may reference that object differently, but they should not each invent their own idea of what counts as the live entitlement.
- If one branch keys off raw share balance while another keys off a side flag or a stale lock amount, replay risk has already started.
-
One shared consumed-state primitive
- Every path that can cash out the entitlement should converge on one state transition that marks the right spent, reduced, or otherwise no longer fully reusable.
- The safest design is not
remember to update the same fields in two places; it iscall the same internal consume primitive from both places. - This is the joint that broke in Eleven Finance, where
emergencyBurn()paid out but did not consume the same share entitlement thatwithdrawAll()later spent.
-
Consumption before economic reuse becomes possible
- The consumed-state update should happen before later loop iterations, sibling branches, callbacks, or follow-on calls can still observe the right as fresh.
We clear it eventuallyis not enough if aggregation, transfer, or re-entrant control flow already priced the old state as spendable.- This is the joint that breaks in late-invalidation and batch-duplicate variants like Balancer Merkle Orchard.
-
Branch-specific mechanics layered after shared entitlement consumption
- Emergency exits may unwrap a strategy differently, migrations may refund leftovers, and ordinary withdrawals may charge different fees — but those are secondary mechanics.
- The primary invariant is still that every branch is spending the same economic right exactly once.
- If branch-specific operational code runs instead of the shared consumed-state path, the protocol has turned control-flow variety into economic ambiguity.
-
Repeat execution should collapse to zero additional value
- A healthy system is idempotent at the entitlement level: the second exercise of the same right should return zero new value, revert cleanly, or observe already-consumed state.
- This is the simplest practical test for whether the design is sound. If calling the same exit twice, or calling two sibling exits back-to-back, can move more value than one right should authorize, the consumed-state boundary is split.
What sound design looks like
-
One entitlement, many views, one spender boundary
- User-facing APIs may expose
withdraw,redeem,emergencyExit,cancel,claim, ormigrate, but all of them should eventually consume the same canonical right. - Differences in UX or operational urgency should not create differences in whether the right still exists afterward.
- User-facing APIs may expose
-
A shared internal consume step
- The safest shape is an internal primitive that computes the live claim, marks it spent or reduced, and only then hands control to branch-specific payout logic.
- If each public function open-codes its own
decrement shares,clear lock,set claimed, ordelete recordsequence, reviewers should assume drift will eventually appear.
-
Emergency paths should be stricter, not looser, about consumed state
Emergencydoes not meanskip accounting; it meansskip non-essential strategy or convenience logic while preserving the same economic finality.- The right mental model is
same entitlement, different unwind mechanics, notspecial branch that moves funds out quickly and we'll reconcile later.
-
Refunds and leftovers should derive from the same consumed record
- Migration and unwind helpers often need to split one redeemed position into
new position value,returned excess,fees, orresidual assets. - Those branches are safest when every payout amount is derived from one already-consumed canonical record rather than from parallel branch-local state.
- Migration and unwind helpers often need to split one redeemed position into
-
Cross-branch tests should matter more than happy-path tests
- Good tests do not stop at
withdraw worksandemergency exit worksindependently. - They ask whether
withdraw(); withdraw(),emergencyExit(); withdraw(),claim([id,id]), orunlock(before expiry); unlock(before expiry)can ever exceed one entitlement’s value.
- Good tests do not stop at
-
Cross-domain refund branches still spend one exit right
- Timeout, failed-bridge, failed-migration, and cross-domain refund handlers are not accounting side quests; they are payout branches spending a single-use entitlement keyed by message, packet, nonce, or route-local refund record.
- The dangerous shape is the same as local replay bugs: value is returned before the underlying packet / sequence / claim key is irreversibly marked spent, so a callback or repeated handler run can keep cashing the same exit right.
- Astroport / Terra is the clean non-EVM reminder that
one timed-out packet == one refund rightneeds the same consumed-state discipline asone vault share == one withdrawal right.
Useful positive design shapes
1. Shared redemption core with branch-specific adapters
- A strong shape is
consume entitlement once -> branch-specific unwind adapter -> transfer outputs. - That keeps the economic state transition centralized even when different asset paths, routers, or emergency procedures are needed.
2. Explicit entitlement state machines
- Lockers, claims, and vault exits become safer when the storage record itself exposes states like
Active -> Consumed,Pending -> Claimed, orLocked -> Unlocked, instead of letting each branch infer finality from ambient balances or timestamps. - The point is not ceremony; it is making
already spentimpossible to forget.
3. Zero-value re-execution as an invariant, not a courtesy
- Systems should treat
repeating the same logical exit adds zero valueas a first-class property worth invariant testing. - That catches both same-call duplicate aggregation and cross-call replay branches.
4. Emergency-mode paths that still reconcile through ordinary finality
- The safest emergency designs route users back into the same burn / claim / clear primitive the normal path uses, even if downstream strategy unwinds or settlement ordering differ.
- If the emergency path cannot do that, it should usually escrow output or pause until it can, rather than transfer value against a still-live claim.
Design questions worth asking first
- What exact storage object proves this user still has a live exit right?
- Which public functions can spend that right today?
- Do all of them call the same internal consumed-state primitive?
- Is consumed state written before value transfer, before later loop iterations, and before any callback or re-entry window can observe the old entitlement?
- If one branch is labeled
emergency,migration,refund,timeout,cancel, orhelper, what exact economic right is it spending, and where is that right extinguished? - For bridge or IBC-style refunds, what exact packet / message / sequence / nonce key is the entitlement object, and is it irreversibly consumed before middleware or receiver callbacks can recurse?
- If the same user calls two sibling exit functions back-to-back, what guarantees the second one returns zero additional value?
Red flags that usually mean the exit boundary is relying on folklore
This helper is only for emergenciesThis branch moves funds out now and updates accounting elsewhereWithdraw and emergency exit are similar enoughwithout one shared internal consume primitiveThe second call will probably fail because balances are lower nowinstead of because the entitlement is definitively consumedThe batch won't contain duplicatesorusers won't call both branchesThe migration path is trusted because the old and new systems are ours
Failure-prevention checklist
- Define one canonical exit-entitlement key and make every
withdraw,redeem,unlock,claim,cancel,migrate,timeoutRefund, andemergencyExitpath resolve down to that same object before any payout logic starts. - Centralize consumption in one internal primitive so the shared invariant is
authenticate live entitlement -> compute live amount -> consume/reduce canonical right -> run branch-specific unwind -> transfer outputs, not several manually synchronized cousins. - Consume or reduce the entitlement before later loop iterations, sibling calls, callbacks, or follow-on branches can still observe it as fresh. Late invalidation is already a replay surface.
- Make emergency and migration branches stricter, not looser, about finality. If a fast path cannot safely consume the same canonical right, it should escrow, pause, or fail closed rather than transfer value against still-live state.
- Derive refunds, leftovers, partial payouts, and migration outputs from the same already-consumed canonical record, not from branch-local caches or ambient balances.
- Treat
second exercise returns zero additional valueas a first-class invariant. Repeating the same logical exit through the same function or a sibling function should revert, return zero, or observe already-consumed state. - For bridge-, packet-, or timeout-based refunds, consume the refund key before middleware callbacks or downstream delivery logic run.
One timed-out packet,one failed message, orone refund nonceshould become dead state before any re-entrant or repeated handler can ask to be paid again. - Keep state-machine status explicit for high-value exits:
Active,Partially consumed,Claimed,Unlocked,Migrated,Cancelled, or equivalent states should be hard to confuse and hard to skip. - Test cross-branch sequences, not just single-path happy cases:
withdraw -> withdraw,emergencyExit -> withdraw,unlock -> unlock,claim([id,id]),migrate -> withdraw, and partial-then-full redemption sequences should all preserve one-right / one-payout semantics. - Monitor for impossible replay signals in production: repeated consumption of one lock id, epoch, share record, or withdrawal ticket; residual state that stays live after payout; and sibling branches paying against the same object within one block or incident window.
Audit prompts
- What exact storage object or proof object says this user still has a live exit right, and where is that object resolved before branch-specific payout logic begins?
- Which public and helper functions can spend that right today, including emergency, migration, cancel, refund, timeout, admin-assist, and batch cousins?
- Do all of those surfaces call the same internal consume primitive, or are reviewers relying on several manually synchronized storage-update sequences?
- Is consumed state written before value transfer, before later loop iterations continue, and before any callback, hook, or externalized unwind can still observe the old entitlement as fresh?
- For packet- or message-keyed refunds, where exactly is the
(channel, port, sequence), bridge nonce, or failed-message key consumed relative to middleware callbacks, receiver hooks, or retry paths? - If a branch pays partial value, what exact canonical record now represents the remaining right, and how is double-counting prevented across later branches?
- Can a migration or emergency helper create value today while leaving the original claim, lock, or share record still redeemable tomorrow?
- Are refunds and leftovers derived from an already-consumed canonical record, or from branch-local state that can drift from the real entitlement boundary?
- If the same user executes two sibling exit paths back-to-back, what exact state transition forces the second one to observe zero additional value?
- What telemetry, reconciliation, or invariant monitoring would reveal that one entitlement was spent twice before the loss became systemic?
Lessons that should already be standard
Emergency pathis not a permission slip to skip accounting. It is usually the place where accounting has to be most conservative because operators are already stressed.- Timeout and refund handlers are payout paths too. Cross-domain middleware does not get a looser one-shot rule just because the entitlement is named by a packet or nonce instead of a share id or lock record.
- Duplicate payout bugs are rarely just
bad looping. They usually mean the protocol never had one canonical answer towhat exact right just got spent? - A safe ordinary withdrawal path does not buy much if the migration, refund, or emergency cousin can still move value against the same live record.
- Idempotence is a balance-sheet property, not a UX nicety. The second attempt should add zero economic value because the right is gone, not because ambient balances happen to be lower.
- Branch-specific mechanics should sit below one shared entitlement-consumption boundary. Once each branch starts open-coding its own finality story, replay risk is only a matter of time.
- Refunds, leftovers, and partial exits are where many systems quietly reintroduce a second accounting universe. If they are not derived from the consumed record, they are probably drift surfaces.
- The clean reviewer question is brutally simple:
after this branch pays, what exact right no longer exists?If the answer is fuzzy, the design is already too weak.
How to use this note with the exploit corpus
- Start here when the sharper question is
what one entitlement object should all payout branches have been forced to consume? - Drop into idempotency failure when the main task is classifying replayable unlock, claim, or redemption bugs broadly.
- Pair with Migration trust-boundary failure when the dangerous branch is a cutover helper that credits new value before the old right is irreversibly consumed; Team Finance is the clean reminder that migration leftovers and refund rails are still spending one legacy lock right, not creating a separate helper-local payout universe.
- Pair with Vault share truth as a single security object when the redeemed object is a vault share whose meaning has to stay coherent across issuance, presentation, and exit paths.
- Pair with no ambient internal balance trust when the later question is not
did two branches spend the same right?butwhy was this internal state cashable as real money at all?
Internal linkages
- Canonical exploit umbrella: duplicate-claim-idempotency-failure.
- Migration-side cousin: migration-trust-boundary-failure.
- Vault-share cousin: vault-share-truth-security-object.
- Downstream cash-out cousin: exit-right-provenance-binding.
- Strong concrete exemplars: Eleven Finance, DxLock, Balancer Merkle Orchard, Mirror Protocol, Level Finance ReferralControllerV2 for the reward-epoch cousin where one
(user, epoch)entitlement stayed replayable inside a batch, and Terra for the cross-domain timeout cousin where one(channel, port, sequence)refund right stayed replayable across middleware callbacks. - Migration-refund exemplar: refund drain (2022-10-27) — one attacker-owned lock record satisfied migrate-side authorization while a different victim LP position was actually consumed, so the leftover payout rail inherited authority from the wrong entitlement object.
Control surface
-
The decisive control surface is the boundary between
this right is still liveandthis right has already been spent. -
In practice that means storage keys, branch convergence, consumed-state timing, and whether value transfer is ever allowed to outrun entitlement finality.
-
Healthy payout systems treat emergency and ordinary exits as different operational routes through the same economic state machine, not as separate accounting universes.
-
Sources:
- https://peckshield.medium.com/eleven-finance-incident-root-cause-analysis-123b5675fa76
- https://blog.decurity.io/dx-protocol-vulnerability-disclosure-mitigation-of-a-critical-smart-contract-exploit-bddff88aeb1d
- https://blocksec.com/blog/how-the-mirror-protocol-got-exploited
- https://medium.com/balancer-protocol/logic-error-bug-fix-review-da37f0cc9a08
- https://medium.com/immunefi/balancer-logic-error-bugfix-review-74f5edca8b1a
- https://github.com/cosmos/ibc-go/security/advisories/GHSA-j496-crgh-34mx
- https://medium.com/@Rarma_/terra-ibc-hooks-exploit-21cca87d70a4
-
Last reviewed: 2026-07-31 UTC