Duplicate Claim / Idempotency Failure

  • Name: Duplicate claim / idempotency failure
  • Category: exploit pattern / replayable payout / duplicate unlock / missing uniqueness guard / batched-claim accounting failure
  • Summary: These exploits break payout, vesting, bridge-claim, refund, or lock-release systems by violating a simple invariant: each claimable entitlement should be consumable exactly once. The vulnerable contract accepts the same position ID, proof, index, nonce, claim descriptor, packet commitment, or timeout right more than once — often inside one batch call or callback window — and pays out each duplicate as if it were fresh. If the claim amount itself can also be inflated upstream, the duplicate-consumption bug turns a bookkeeping mistake into a major drain.
  • What it is:
    • A contract tracks some one-time right: a locked position, vesting claim, reward epoch, Merkle leaf, withdrawal ticket, or bridge message.
    • A payout path builds a batch of claimable items and sums their value.
    • The code fails to enforce that each entitlement appears at most once, or fails to mark it consumed before later logic can see it again.
    • The attacker repeats the same identifier or replayable claim object and gets paid multiple times for one economic right.
    • The protocol loses funds because the payout path is not idempotent: repeating the same logical action changes state and value more than once.
  • Typical preconditions:
    • The protocol exposes a batched release / claim / unlock function that accepts arrays of IDs, proofs, or tickets.
    • The contract computes payout by iterating over user-supplied entries rather than by deriving a canonical unique set internally.
    • Duplicate entries are not rejected, or the consumed-state write happens too late to stop reuse.
    • The contract holds real assets or release rights immediately transferable to the caller.
    • In the worst cases, a separate accounting bug can first inflate the amount attached to one claimable position.
  • Common vulnerable surfaces:
    • batched unlock functions over position IDs
    • Merkle distributors that do not key claims uniquely or set bitmap state early enough
    • vesting / reward claims that trust caller-supplied arrays without deduplication
    • bridge withdrawal executors that can replay the same message or leaf
    • timeout / refund handlers that delete packet commitments or consumed markers only after invoking hooks, memos, or middleware callbacks
    • helper functions that pre-compute claimable records, then mutate state only after summing the payout
  • Failure sequence:
    1. The attacker creates or locates a claimable entitlement with non-trivial value attached.
    2. They call the release function with the same ID, proof, or claim object repeated.
    3. The contract iterates over the duplicates and adds each one into unlock_amount / claim_amount / withdraw_amount as if it were distinct.
    4. State cleanup either happens only once, happens too late, or relies on assumptions about caller honesty.
    5. The contract transfers the inflated aggregate amount to the attacker.
    6. If the entitlement amount was itself manipulable, the replay multiplies that earlier accounting error into protocol-scale loss.
  • Variants:
    • Duplicate IDs in one batch — the same position index or claim index appears many times in one call.
    • Cross-call replay — consumed state is never written, is written to the wrong key, or is only cleared on the wrong branch, so the same proof, lock, or claim can be exercised again later.
    • Late invalidation — the contract validates eligibility before mutating state, but another path can re-enter or otherwise preserve replayability before invalidation lands.
    • Callback-mediated late invalidation — a one-shot refund, withdrawal, or timeout right remains live across a hook or callback boundary, so recursive execution can spend the same commitment before deletion.
    • Inflated-claim replay — a separate accounting bug first makes one claim unusually large, then the replay bug multiplies that oversized claim.

Three claim-authority promotions reviewers should not collapse

  1. A claim-shaped artifact exists at all

    • A lock ID, Merkle leaf, epoch tuple, packet commitment, vesting ticket, or withdrawal right is present somewhere in protocol state or calldata.
    • This is only the reachability question: there is some object the payout path might later treat as claimable.
  2. That artifact counts as canonical live entitlement truth

    • The harder question is whether this exact object is still the one economic right that should be honored now, whether it is unique in the batch, whether it has already been partially or fully consumed, and whether every helper or callback path is still talking about the same entitlement object.
    • This is the entitlement-truth question: the protocol is looking at one still-live right, not at two caller-shaped aliases or one right that should already be dead.
  3. That accepted entitlement still deserves payout authority here and now

    • Even a real claimable right should not automatically inherit repeated batch inclusion, callback-time liveness, emergency-path equivalence, or cross-route payout authority.
    • This is the cash-out question: why is this accepted entitlement allowed to increase totalClaimable or move reserves again right now?

Healthy claim systems keep those promotions separate instead of flattening them into one vague claim was valid story.

  • Detection heuristics:
    • For every one-time payout path, ask: what exact key proves uniqueness, and where is it enforced?
    • Review any function that accepts user-supplied arrays of claim identifiers; duplicates should be rejected explicitly or canonicalized away.
    • Compare when payout is accumulated versus when consumed state is deleted or marked.
    • For callback-capable systems, verify that packet commitments, nonces, or consumed markers are removed before any hook, memo callback, or middleware can recursively invoke the same payout path.
    • Test adversarial inputs like [id, id], [id, id, id], and mixed-order duplicates inside one batch.
    • Look for accounting based on ambient contract balance or other inferred state that can make one claim much larger than intended before replay occurs.
  • Defenses:
    • Make one-time payout functions explicitly idempotent: repeating the same logical claim should have no additional effect.
    • Reject duplicate identifiers inside a batch before any payout aggregation happens; Mirror’s post-incident unlock_positions_funds() patch is a good minimal example of replacing blind summation with an explicit seen-set / duplicate-revert loop.
    • Mark claim state consumed before transferring value or before later loop iterations can count it again.
    • Prefer canonical storage-derived claim sets over caller-supplied duplicate-prone arrays.
    • Add invariant tests asserting that total payouts cannot exceed the sum of distinct claimable entitlements.

What sound design looks like

  • Claim authority stays staged rather than collapsing into one claim exists fact. Healthy systems keep a claim-shaped artifact exists, that artifact is the one still-live entitlement, and that accepted entitlement still deserves payout authority right now as separate promotions with explicit consumed-state answers at each step.
  • One economic entitlement should map to one canonical consumed-state key. The system should not need to infer uniqueness from array position, loop order, helper-call discipline, or user honesty; it should be able to answer plainly which storage object represents this claim has already been spent.
  • Batch payout logic should be a convenience wrapper around already-safe single-claim semantics, not a second accounting universe. If processing [a, b, c] produces a different economic result than executing a, then b, then c against the same consumed-state machine, the batch path is already too clever.
  • Payout aggregation should only happen after uniqueness is resolved. A healthy claim function first proves these are the distinct entitlements being consumed, then marks them spent, then computes and transfers value; it should never let caller-supplied duplicates participate in the sum while uniqueness remains hypothetical.
  • All exit routes that honor the same economic right should consume the same entitlement object. Emergency exits, standard withdrawals, bridge claims, timeout refunds, reward harvests, and migration helpers should not each maintain their own replay story for what is supposed to be one-time value.
  • The safest reviewer question is brutally simple: if I submit the same logical claim twice — in one batch, across two calls, via two helper paths, or before and after a partial state transition — where exactly does the second attempt collapse to zero additional value?

Failure-prevention checklist

  • Review three separate promotions every time: claim-shaped artifact exists, artifact is the one still-live entitlement, and that entitlement still deserves payout authority through this exact path right now. Healthy systems prove all three instead of assuming one implies the next.
  • Define the canonical uniqueness key for every one-time payout path: claim index, leaf hash, lock ID, epoch tuple, nonce, position ID, or other exact entitlement identifier.
  • Enforce duplicate rejection or canonical deduplication before payout aggregation. Arrays of user-supplied claim descriptors should never influence totalClaimable until uniqueness has already been established.
  • Mark claims consumed before value transfer and before any later loop iteration can observe them as still live.
  • Make batch helpers economically equivalent to repeated single-claim execution against the same state machine; property-test that [id, id] cannot produce more value than [id].
  • Ensure every alternative exit path burns, clears, or otherwise consumes the same shared entitlement object. emergencyWithdraw, claimMultiple, unlock, withdrawAll, timeout handlers, migration helpers, and rescue paths should not fork consumed-state semantics.
  • In callback-capable systems, delete packet commitments, mark refund rights consumed, or otherwise finalize one-shot state before invoking user-controlled hooks, memos, or middleware callbacks.
  • Use exact additive claim advancement where partial claims exist. Any path that overwrites progress with = instead of advancing with +=, bitmap-set, or monotonic nonce movement should be treated as replay-sensitive.
  • Add invariants bounding total paid value by the sum of distinct live entitlements, not by the number of loop iterations or caller-supplied entries.
  • Fuzz duplicate ordering, repeated proofs, repeated IDs, empty-plus-duplicate mixes, and cross-call replay attempts, including reentrancy or callback windows where consumed-state writes could land late.
  • Keep claim amount derivation separate from ambient contract-balance deltas or other manipulable state that can enlarge one replayable entitlement before the replay bug multiplies it.
  • Monitor claimed-state transitions and payout totals so duplicate consumption attempts or anomalous per-entitlement payout growth surface before pooled funds are exhausted.

Audit prompts

  • Which promotion is happening at each step here: claim-shaped artifact exists, that artifact still counts as the one canonical live entitlement, or that accepted entitlement is allowed to increase payout authority through this path right now?
  • What exact storage key or commitment object proves that this entitlement has already been consumed, and is that key shared across every path that can honor the entitlement?
  • Can a caller submit [id, id], [proof, proof], or the same epoch / position / leaf in mixed order and make the batch total grow before consumed-state is updated?
  • Does the contract aggregate payout before it marks claims spent, and if so what prevents later loop iterations, reentrant paths, or helper branches from counting the same right again?
  • Are there multiple exit routes that all pay against the same economic right but consume different state objects or only some of them burn the underlying share / lock / ticket?
  • Could a timeout, refund, on-receive, or post-transfer callback re-enter the same one-shot path before its packet commitment, nonce, bitmap bit, or claim marker is deleted?
  • Where does partial-claim progress live, and could assignment, truncation, or stale-cache semantics let an already-paid interval become claimable again?
  • If a claim proof, nonce, or ticket is replayed across two separate calls, what exact monotonic state transition makes the second call economically inert?
  • Can one oversized entitlement be manufactured upstream through balance-difference accounting, stale snapshots, or manipulable exchange-rate logic and then multiplied by a replay bug downstream?
  • Are helper contracts, upgrade migrations, or emergency escape hatches reusing the main entitlement-consumption primitive, or did they quietly fork uniqueness logic?
  • Does testing assert that total payouts over time never exceed the sum of distinct entitlements that ever became valid, even under adversarial batch ordering?
  • If a reviewer traces a single claim from creation to settlement, where exactly does the protocol make second exercise == zero additional value true by construction rather than by convention?

Lessons that should already be standard

  • Claim exists is weaker than claim is still live, which is weaker than this accepted claim still deserves payout authority through this path right now. Duplicate-claim incidents usually live in the gaps between those promotions.

  • One-time rights need one-time state. If a protocol cannot point to the exact consumed-state object for a claim, replay risk is probably already hiding in helper logic.

  • Batch convenience is not a free abstraction. Many duplicate-claim bugs appear because teams reasoned about the single-claim path while the exploitable economics lived in the batch aggregator.

  • Checks-effects-interactions is not enough if the protocol sums value before it resolves uniqueness. Replay safety starts at entitlement identity, not at the final token transfer.

  • Emergency, migration, and refund paths are often where idempotency discipline goes to die. If they pay real value, they belong inside the same consumed-state model as ordinary exits.

  • Cross-domain middleware does not get a carve-out from one-shot accounting. If a timeout right, bridge packet, or callback-triggered refund is meant to be exercisable once, its consumed-state transition has to land before any external code gets a chance to recurse.

  • Small arithmetic or bookkeeping regressions — = versus +=, wrong bitmap bit, stale cache, late delete — can silently turn a valid claim into a printing press.

  • Upstream accounting and downstream replay bugs compound each other brutally. A protocol should assume any inflated entitlement will be exercised more than once unless the payout path makes repetition worthless.

  • The clean invariant is stronger than duplicate inputs revert: the protocol should be architected so that any repeated logical entitlement, in any order or path, yields no extra value.

  • High-signal incidents:

    • Mirror Protocol lock contract duplicate unlock (2021-10-08) — the clean lock-release exemplar: unlock_positions_funds() accepted the same position_idx hundreds of times, so one locked amount was released over and over again; the blast radius was amplified because the position’s locked_amount had first been inflated through balance-difference accounting in the lock contract.
    • [[../../../incidents/accounting-issuance-claims/eleven-finance-elevenneversellvault-emergencyburn-missing-share-burn-double-withdrawal-drain-2021-06-22|Eleven Finance ElevenNeverSellVault emergencyBurn() missing share burn / double-withdrawal drain (2021-06-22)]] — the vault-redemption cousin: emergencyBurn() returned underlying LP value without burning the user’s vault shares, so the same share balance could still be redeemed through withdrawAll() for a second payout.
    • DxLock pre-expiry unlock replay near miss (2023-06-28) — the cross-call replay cousin: one real LP-token locker stayed reusable before expiry because unlockToken() transferred value out without clearing the recorded amount on that branch, so the same locker could have been exercised repeatedly against pooled liquidity for the same LP token.
    • Balancer Merkle Orchard duplicate-claim drain bug (2023-01-22) — the Merkle-distributor cousin: one real leaf and one real proof could be repeated many times inside a single claimDistributions() batch because payout amounts were accumulated before duplicate-claim bits were committed.
    • [[../../../incidents/accounting-issuance-claims/level-finance-referral-controller-v2-claimmultiple-epoch-reuse-drain-2023-05-01|Level Finance ReferralControllerV2 claimMultiple() epoch-reuse drain (2023-05-01)]] — the rewards-controller cousin: one reward epoch could be repeated inside a batch because claimMultiple() overwrote claimed with = instead of advancing it with +=, turning one epoch entitlement into a replayable LVL payout.
    • Terra IBC hooks timeout-callback reentrancy counterfeit-voucher drain (2024-07-31) — the cross-domain callback cousin: one timed-out ICS-20 refund right stayed live until after the ibc-hooks callback window, so recursive MsgTimeout execution could spend the same packet commitment repeatedly before deletion.

Internal linkages

Control surface