Ambient Payment-Context Reuse / msg.value Replay Across Batched Subcalls

  • Name: ambient payment-context reuse / msg.value replay across batched subcalls
  • Category: exploit pattern / payment provenance failure / ambient call-context reuse / native-asset accounting / batch-composition bug
  • Summary: These exploits happen when a contract treats msg.value as proof that this specific logical sub-operation received fresh native-asset payment. That assumption is only safe when one payable action consumes one call frame. Once the same contract can execute multiple logical actions inside one outer call — via an internal loop, batching, delegatecall, or multicall — every subcall can inherit the same ambient msg.value. If the code credits commitments, exercises options, mints assets, or computes refunds separately for each subcall, the same ETH or MATIC can be counted over and over. The dangerous leap is semantic, not arithmetic: msg.value > 0 proves the outer transaction carried native assets, but it does not prove that each nested exercise, purchase, mint, or bid inside that transaction brought in fresh value of its own, or that any still-unconsumed remainder exists for later branches to spend or refund.
  • What it is:
    • A protocol has a payable path whose accounting is keyed directly off msg.value.
    • That payable path becomes reachable more than once inside one transaction through a batch helper, delegatecall loop, multicall router, or explicit internal iteration.
    • Each logical subcall observes the same ambient payment context even though only one real native-asset transfer happened at the transaction boundary.
    • The protocol treats each observation as a fresh payment and issues commitments, purchases, mints, or refunds accordingly.
    • If partial-fill or oversubscription logic exists, the false repeated payment context can escalate from free participation into direct drain via repeated refunds.
  • Typical preconditions:
    • The native-asset path uses msg.value directly instead of an internally consumed remainingValue ledger or an explicit pre/post balance check.
    • The contract inherits or exposes a batching primitive such as batch, multicall, or an internal loop over user-supplied actions.
    • The batch primitive preserves outer-call context for nested execution, as delegatecall does.
    • The payable sub-operation can be run multiple times in one transaction without a one-shot guard.
    • The economic logic contains either claim issuance on each subcall or refund logic that independently returns msg.value - acceptedAmount.
  • Common vulnerable surfaces:
    • Dutch auctions and token-sale commitment functions that partially fill and refund the excess
    • NFT mint or in-game purchase flows that price each action from msg.value
    • options exercise or settlement loops where one ETH payment can be reused across multiple exercises
    • generalized batch() helpers mixed into contracts that were only reviewed as single-call entrypoints
    • native-asset wrappers that assume the payable path will never be invoked repeatedly inside one transaction
  • Exploit sequence:
    1. The attacker identifies a payable function whose accounting or refund math depends directly on msg.value.
    2. They find a way to invoke that function multiple times inside one outer transaction, usually through delegatecall-based batching or an internal loop.
    3. They send one real native-asset payment with the outer call.
    4. Each logical subcall reuses the same ambient msg.value and is treated as if fresh payment arrived again.
    5. The protocol repeatedly credits commitments, mints assets, or reaches a partial-fill branch that refunds excess value on each subcall.
    6. The attacker exits with free claims, inflated purchasing power, or more native assets refunded than were actually provided.
  • Variants:
    • Direct loop replay — a function iterates through vault exercises, mints, or purchases and checks msg.value each time instead of decrementing a consumed native-asset budget. Opyn is the clean preserved case.
    • Delegatecall batch replay — a helper like batch(bytes[] calls, ...) uses delegatecall(address(this), ...), so every nested payable function sees identical msg.sender and msg.value. MISO is the canonical batch-helper variant.
    • Refund amplification — each subcall first accepts some capped amount and then refunds msg.value - accepted, letting an attacker repeat the refund leg after the contract is effectively full.
    • Free-allocation participation — even without refunds, repeated msg.value reuse can let an attacker place bids or obtain allocations they did not really pay for, corrupting auction outcomes.

Three payment-context questions reviewers should not collapse

  1. Did this transaction carry native assets at all?

    • This is the weakest fact: the outer call arrived with nonzero msg.value or some other ambient native-payment context.
    • On its own, that only proves some value entered this transaction frame somewhere.
  2. Did this exact logical action consume a uniquely assigned slice of that value?

    • This is the harder accounting question: the protocol should be able to point to one action-local debit, receipt row, or monotonically shrinking remainingValue budget for this specific mint, bid, exercise, or purchase.
    • Opyn and MISO both preserve different ways this middle proof disappeared; the code kept reusing transaction-level payment as if it were fresh action-level payment.
  3. After that consumption, did any remaining budget still deserve later refund or entitlement authority?

    • Even if one action genuinely consumed some portion of the transaction’s value, that does not mean later branches may still act as if the whole outer payment is unspent.
    • The catastrophic versions of this family often cash out here, when a partial-fill or refund path quietly promotes outer transaction carried value into this branch may still refund or allocate against the same full amount again.
  • Detection heuristics:
    • Search for any payable function that references msg.value; then ask whether that function can be reached from multicall, batching, delegatecall, router callbacks, or explicit loops.
    • Compare native-asset and ERC-20 code paths. If ERC-20 logic uses transferFrom per operation but ETH logic only checks msg.value, the native path deserves extra scrutiny.
    • Review partial-fill or hard-cap code carefully: a repeated refund branch is often more dangerous than the free-bid branch.
    • Treat inherited batch helpers as part of the threat model even when the target function itself looks correct in isolation.
    • Ask whether one transaction can cause more than one economic object — mint, bid, exercise, claim, or refund — to rely on the same outer-call payment context.
  • Defenses:
    • Prefer ERC-20 style pull accounting or wrap native assets into WETH so every logical payment goes through an explicit transfer path.
    • If native assets must be accepted, track a consumed payment budget explicitly rather than reading raw msg.value inside each sub-operation.
    • For batch helpers, forbid repeated invocation of payable accounting functions or require the caller to pre-split value and pass per-call amounts that are internally debited.
    • Validate real balance growth or an explicit internal receipt variable instead of ambient call context when proving payment.
    • Audit compositions, not just components: safe payable function plus safe batch helper can still be an unsafe system.
    • Add adversarial tests that call the same payable function multiple times through the protocol’s batching surface with only one outer msg.value.

What sound design looks like

  • Healthy systems keep three payment facts separate on purpose: this transaction carried native value, this exact logical action consumed this exact slice of that value, and this later branch still has this much remaining budget to refund or spend. The design should never need one ambient msg.value observation to stand in for all three.
  • The contract has an explicit answer to which logical action consumed which native-asset consideration? That answer lives in storage or in transaction-local memory owned by the protocol, not in ambient EVM context. If three sub-operations run, the code can point to three debits against a finite internal value budget rather than rereading one outer msg.value three times.
  • Batch composition does not manufacture payment provenance. A healthy multicall or module executor either forbids value-sensitive function families entirely, pre-allocates per-subcall value and debits it monotonically, or normalizes native assets into WETH before any business logic that can repeat. The helper cannot pretend subcalls are isolated if it preserves one shared payment context.
  • Refunds are derived from consumed consideration, not from msg.value observed at a nested frame. Once one branch allocates x wei to an auction commitment or option exercise, later branches can only refund what remains after that allocation, never replay the full outer-call value as if it were still unspent.
  • Native and token payment paths converge on the same accounting model. The protocol should be able to swap ETH path for WETH path without changing entitlement logic; if native support requires special semantics about ambient context, the design is already carrying extra hidden trust assumptions.
  • Payable functions have explicit transaction-level composition rules. If an action is only safe once per transaction, the contract encodes that as a one-shot rule; if it is safe many times, the contract encodes how payment budget is partitioned across those repetitions. Frontend won't do that is not part of the model.
  • The system’s conservation invariant is machine-checkable: cumulative mints, bids, exercises, allocations, and refunds from one transaction cannot imply more native-asset consideration than actually crossed the transaction boundary. Good design makes that statement easy to test and hard to violate accidentally through future batching upgrades.

Failure-prevention checklist

  • Treat transaction carried native value, this action consumed x, and remaining budget is y as three separate accounting states. Do not let one raw msg.value read silently answer all three questions.
  • Treat native-asset payment as a consumable resource, not ambient context. If one transaction can perform many logical actions, keep an internal remainingValue or per-action receipt ledger and decrement it exactly once per action.
  • Decide whether a payable function is semantically single-use per transaction. If yes, enforce it with a one-shot guard instead of assuming callers will not reach it again through batching or internal recursion.
  • Review every batching surface together with every payable accounting surface: multicall, batch, routers, module executors, delegatecall(address(this), ...), and internal loops that fan out over user-supplied actions.
  • For refund logic, compute refund eligibility from an internally consumed amount, not from raw msg.value observed at each nested frame. Repeated partial-fill branches are often the real payout path.
  • Prefer explicit per-subcall amounts passed through calldata and checked against a debited budget over if (msg.value >= price) style gates inside nested logic.
  • Unify native and ERC-20 accounting semantics where possible. If the ERC-20 path proves payment with transferFrom per action but the native path only checks ambient call context, the native path should be presumed higher risk until proven otherwise.
  • Keep balance-growth checks and payment-consumption checks aligned. A function should not both trust ambient msg.value and separately issue claims based on later state that never proves fresh native value entered.
  • Fuzz compositions, not only entrypoints: one outer payment with repeated inner payable calls, mixed success and revert branches inside batching, partial-fill refund paths near caps, and nested router/module execution.
  • Expose operational kill switches for batch helpers that touch value-moving paths. Many protocols can pause the sale or vault but forget the helper that makes payment-context reuse reachable.
  • Write an invariant in tests and reviews that the total claims, allocations, exercises, or refunds produced inside one transaction cannot imply more native-asset consideration than the transaction actually delivered.

Audit prompts

  • Which payment-context question is each branch answering here: the transaction carried value, this exact action consumed value, or this later branch still has value left to refund or spend? Where are those states stored separately?
  • Which exact logical action is msg.value supposed to pay for here, and what stops the same outer-call value from being observed by a second action in the same transaction?
  • Can this payable function be reached through any composition surface the original author may not have mentally modeled: delegatecall batchers, proxy module executors, router callbacks, loops over calldata, or inherited multicall utilities?
  • If several inner calls succeed and some fail, how is the remaining native-asset budget tracked across those branches? Does any revert-handling or partial-fill path effectively reset the budget?
  • Does refund math read raw msg.value, or does it read value allocated to this subcall from an internal ledger? If the answer is the former, can the refund leg be repeated?
  • Are there any helper contracts or mixins that preserve call context (msg.sender, msg.value) while pretending to provide isolated subcalls?
  • Is the protocol implicitly trusting social rules like users will only call this once or frontends do not expose multicall instead of enforcing single-use semantics onchain?
  • If the native-asset path were rewritten to WETH-style explicit transfers, would any business logic change? If yes, that difference often highlights where ambient context is standing in for real payment proof.
  • What invariant proves that cumulative allocations, collateral exercises, or refunds from one transaction are bounded by the transaction’s actual native-asset inflow?
  • Can an attacker place the system near a cap or partial-fill boundary first, so the economically meaningful exploit becomes repeated refunds rather than repeated purchases?
  • Are pause controls, monitoring, and incident playbooks aimed at the payable function itself, or at the composition route that actually makes repeated observation possible?

Lessons that should already be standard

  • Transaction carried value, this action consumed value, and later branches still have budget left are different claims. Healthy native-asset accounting keeps them disjoint instead of letting one ambient msg.value observation answer all three.

  • msg.value proves transaction-level payment happened, not action-level payment provenance. Treating it as reusable evidence for each nested step is a category error.

  • Native-asset support is not the same as ERC-20 support with nicer UX. Ambient call context has different compositional hazards and needs its own accounting discipline.

  • Batch helpers are part of solvency logic when they can reach payable code. A harmless-looking delegatecall convenience layer can become the exploit primitive that monetizes an otherwise local accounting assumption.

  • Refund branches deserve the same paranoia as mint branches. Many msg.value replay bugs become catastrophic only when repeated partial refunds turn semantic confusion into direct asset extraction.

  • Works in isolation is not a security property for payable functions. If the protocol ships composition surfaces, the threat model must be defined at the composed-system level.

  • If one real payment can satisfy more than one logical entitlement story, the design does not yet have payment provenance — it has ambient context dressed up as accounting.

  • Explicit internal journals beat ambient EVM context. The protocol should always be able to answer how much native value remains unconsumed for this transaction path? without rereading msg.value.

  • Frontend assumptions are not controls. If calling the same payable action twice in one transaction would break accounting, the contract has to make that impossible.

  • The safest native-asset pattern is often to immediately wrap into WETH and continue as explicit token accounting. Every place that resists that translation deserves extra review.

  • Where this family stops:

    • Stay here when the same real native-asset payment is being counted multiple times across nested actions, subcalls, or loop iterations inside one outer transaction.
    • Pivot to deposit-signal locked-value mismatch when the more important bug is that a bridge or relayer accepted one deposit-looking artifact as proof that custody happened at all. THORChain’s July 2021 router exploits are the sharp cousin here: they also involved misleading native-value context, but the durable bridge lesson is that source-side deposit truth was forged for downstream relayers rather than that one batch helper replayed payment across multiple logical exercises.
    • Pivot to balance-delta-provenance-failure when the protocol is not reusing ambient msg.value, but instead is misattributing a token balanceAfter - balanceBefore delta to the wrong deposit, reward, or callback source.
    • Pivot to reentrancy when repeated execution matters mainly because stale mutable state stayed live across an external callback, even if no native-asset payment context was being reused.
  • High-signal incidents:

    • [[../../../incidents/accounting-issuance-claims/opyn-oeth-put-exercise-msg-value-reuse-double-exercise-collateral-drain-2020-08-04|Opyn oETH Put exercise() msg.value reuse / double-exercise collateral drain (2020-08-04)]] — the direct loop variant: one outer ETH payment satisfied multiple _exercise() checks across a vault list, so the same msg.value unlocked more than one USDC collateral payout.
    • [[../../../incidents/accounting-issuance-claims/sushiswap-miso-dutchauction-batchable-msg-value-refund-drain-near-miss-2021-08-17|SushiSwap MISO DutchAuction batchable msg.value refund-drain near miss (2021-08-17)]] — the delegatecall batch variant: BoringBatchable.batch() preserved ambient msg.value across repeated commitEth() subcalls, and DutchAuction’s partial-fill refund path turned one outer ETH payment into potentially many refunds once the auction approached its cap.

Internal linkages

Control surface