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 dangerous in 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.
    • Emergency and migration code 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 like withdrawAll() versus emergencyBurn() or unlock after expiry versus unlock before expiry.

The five joints a healthy exit system should keep fused

  1. 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.
  2. 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 is call 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 that withdrawAll() later spent.
  3. 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 eventually is 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.
  4. 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.
  5. 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, or migrate, 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.
  • 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, or delete record sequence, reviewers should assume drift will eventually appear.
  • Emergency paths should be stricter, not looser, about consumed state

    • Emergency does not mean skip accounting; it means skip non-essential strategy or convenience logic while preserving the same economic finality.
    • The right mental model is same entitlement, different unwind mechanics, not special 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, or residual assets.
    • Those branches are safest when every payout amount is derived from one already-consumed canonical record rather than from parallel branch-local state.
  • Cross-branch tests should matter more than happy-path tests

    • Good tests do not stop at withdraw works and emergency exit works independently.
    • They ask whether withdraw(); withdraw(), emergencyExit(); withdraw(), claim([id,id]), or unlock(before expiry); unlock(before expiry) can ever exceed one entitlement’s value.
  • 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 right needs the same consumed-state discipline as one 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, or Locked -> Unlocked, instead of letting each branch infer finality from ambient balances or timestamps.
  • The point is not ceremony; it is making already spent impossible to forget.

3. Zero-value re-execution as an invariant, not a courtesy

  • Systems should treat repeating the same logical exit adds zero value as 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, or helper, 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 emergencies
  • This branch moves funds out now and updates accounting elsewhere
  • Withdraw and emergency exit are similar enough without one shared internal consume primitive
  • The second call will probably fail because balances are lower now instead of because the entitlement is definitively consumed
  • The batch won't contain duplicates or users won't call both branches
  • The 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, and emergencyExit path 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 value as 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, or one refund nonce should 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 path is 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 to what 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? but why was this internal state cashable as real money at all?

Internal linkages

Control surface