Opcode Summary

PropertyValue
Opcode0x34
MnemonicCALLVALUE
Gas2
Stack Input(none)
Stack Outputmsg.value
BehaviorPushes the wei value sent with the current CALL onto the stack. Read-only; does not modify state, memory, or storage.

Threat Surface

CALLVALUE is deceptively simple: it reads a single value from the execution context. Yet it is the root cause of one of the most dangerous vulnerability classes in Ethereum smart contracts — msg.value reuse — responsible for hundreds of millions of dollars at risk.

Three properties make CALLVALUE dangerous:

  1. msg.value persists across delegatecall. When contract A delegatecalls contract B, CALLVALUE inside B returns the original msg.value from A’s caller. DELEGATECALL does not accept a value parameter; it inherits the caller’s entire context, including msg.value. This means a batch function using delegatecall in a loop provides the same msg.value to every iteration, enabling double-spending of a single ETH deposit.

  2. msg.value can be reused in loops. Even without delegatecall, a payable function that iterates over multiple items and checks msg.value in each iteration re-reads the same constant value. The ETH is only transferred once at the start of the call, but the check passes every time. This is the pattern that destroyed Opyn ($371K, August 2020).

  3. msg.value in multicall patterns allows double-spending. Modern contracts often implement batch/multicall functions that delegatecall back into themselves for each sub-call. When any of the batched functions are payable, the same msg.value is available to every sub-call. This created a $350M exposure in SushiSwap MISO (August 2021).

Unlike arithmetic overflow (which Solidity 0.8.0 fixed at the compiler level), there is no compiler-level protection against msg.value reuse. It remains a live threat in every Solidity version.


Smart Contract Threats

T1: msg.value Reuse in Loops and Multicall (Critical)

The single most dangerous CALLVALUE pattern. When a payable function loops through multiple items and checks msg.value inside each iteration, the same ETH is counted multiple times. The attacker sends ETH once but receives credit for N iterations.

Two structural variants exist:

Variant A — Direct loop reuse: A payable function accepts an array parameter and processes each element in a loop, checking or using msg.value in every iteration:

function exerciseMultiple(address[] calldata vaults) external payable {
    for (uint i = 0; i < vaults.length; i++) {
        _exercise(vaults[i]);  // Uses msg.value inside -- same value every time
    }
}

Variant B — Delegatecall multicall reuse: A batch/multicall function delegatecalls the contract’s own payable functions. Because delegatecall preserves msg.value, each sub-call sees the full original value:

function batch(bytes[] calldata calls) external payable {
    for (uint i = 0; i < calls.length; i++) {
        (bool ok,) = address(this).delegatecall(calls[i]);
        require(ok);
    }
}
// Each delegatecall to a payable function (e.g., commitEth) sees the same msg.value

Both variants let an attacker multiply the effective value of a single ETH deposit by the number of iterations. This is not a theoretical concern — it has been exploited and nearly exploited for nine-figure sums.

T2: msg.value Persistence Across DELEGATECALL (High)

DELEGATECALL executes the target’s code in the caller’s context: same storage, same msg.sender, and crucially, same msg.value. The DELEGATECALL opcode does not accept a value parameter at all (unlike CALL, which does). This means:

  • A proxy contract forwarding calls via delegatecall always exposes the original msg.value to the implementation
  • The implementation cannot distinguish “the user sent 1 ETH to me” from “the user sent 1 ETH to the proxy, which delegatecalled me”
  • If the proxy or router processes multiple delegatecalls in sequence (as in multicall), each implementation sees the same msg.value

This is by design in the EVM specification, but it creates a dangerous semantic mismatch: developers expect msg.value to represent “how much ETH was sent to this function call,” but in a delegatecall context it represents “how much ETH was sent to the original external call.”

T3: Missing msg.value Validation in Payable Functions (High)

Payable functions that don’t validate msg.value accept arbitrary ETH deposits. Common patterns:

  • No check at all: Function is marked payable for gas savings but never uses msg.value. Sent ETH is locked permanently.
  • Loose check: Function checks msg.value > 0 but not that it matches the expected amount. User overpays and excess is trapped.
  • Missing refund: Function uses only part of msg.value (e.g., for a fixed-price mint) but doesn’t refund the difference.
// Funds permanently locked -- payable with no value handling
function doSomething() external payable {
    // No msg.value check; ETH sent here is irretrievable
    _updateState();
}
 
// Overpayment not refunded
function mint() external payable {
    require(msg.value >= PRICE, "Insufficient");
    _mint(msg.sender, 1);
    // If msg.value > PRICE, excess ETH is locked
}

T4: Zero msg.value Bypassing Guards (Medium)

Some contracts use msg.value as an implicit authentication or commitment signal without checking it against a minimum. An attacker can call payable functions with msg.value == 0 to:

  • Trigger state changes without economic commitment (e.g., “free” bids in auctions)
  • Bypass require(msg.value == expectedAmount) when expectedAmount is derived from a calculation that can be manipulated to zero
  • Enter payable code paths in delegatecall where msg.value is inherited as 0 from a non-payable caller
function bid(uint256 auctionId) external payable {
    auctions[auctionId].bids.push(Bid(msg.sender, msg.value));
    // No minimum check -- zero-value bids pollute the auction
}

T5: msg.value in Receive/Fallback Reentrancy (Medium)

When a contract sends ETH via call{value: amount}(""), the recipient’s receive() or fallback() function executes with msg.value == amount. If the recipient re-enters the sending contract, the original contract’s msg.value context has not changed but its state may be inconsistent. This is the classic reentrancy pattern, where CALLVALUE plays a supporting role by providing the value context that triggers the re-entrant call.


Protocol-Level Threats

P1: No DoS Vector (Low)

CALLVALUE costs a fixed 2 gas with no dynamic component. It reads from the execution context (not from storage or memory) and cannot cause gas griefing or state bloat.

P2: Consensus Safety (Low)

CALLVALUE returns a deterministic value set at call initiation. All EVM client implementations agree on its behavior across all call types (CALL, DELEGATECALL, STATICCALL, CALLCODE). No consensus divergence risk.

P3: STATICCALL Value Behavior (Low)

When a contract is invoked via STATICCALL, CALLVALUE returns 0 because STATICCALL does not permit value transfer. This is well-specified, but a contract relying on msg.value > 0 as an indicator that it was called externally (not via staticcall) could be confused by a CALL with value=0, which behaves identically to staticcall from msg.value’s perspective.

P4: EIP-7069 Call Instruction Changes (Informational)

EIP-7069 proposes new call instructions (EXTCALL, EXTDELEGATECALL, EXTSTATICCALL) that simplify value transfer semantics. EXTDELEGATECALL, like DELEGATECALL, does not accept a value parameter. The msg.value reuse problem persists under the new instruction set. Protocol developers should not assume EIP-7069 mitigates CALLVALUE-related threats.


Edge Cases

Edge CaseBehaviorSecurity Implication
msg.value in DELEGATECALLReturns the original caller’s msg.value, not 0Enables msg.value reuse across batched delegatecalls
msg.value in STATICCALLAlways returns 0 (value transfer prohibited)Contracts using msg.value > 0 as a guard will always fail under staticcall
msg.value == 0 in payable functionFunction executes normally; no ETH transferredZero-value calls bypass economic commitment checks
msg.value in multicall (delegatecall loop)Same msg.value available in every sub-callDouble/triple/N-spending of a single ETH deposit
msg.value in CALLCODEPreserves original msg.value (like DELEGATECALL)Same reuse risk; CALLCODE is deprecated but still functional
msg.value with CREATE/CREATE2CALLVALUE in constructor returns the value sent to CREATEConstructor can receive ETH; no reuse risk (single execution)
msg.value > address(this).balance at call timeImpossible; EVM enforces balance >= value before executionNo underflow risk at the opcode level
msg.value persistence after internal function callmsg.value unchanged throughout the entire external callInternal functions always see the same msg.value as the entry point

Real-World Exploits

Exploit 1: Opyn ETH Put Options — $371K Stolen (August 2020)

Root cause: msg.value reused in a loop across multiple vault exercises.

Details: Opyn’s exercise() function for ETH put options accepted an array of vaults (vaultsToExerciseFrom[]) and called an internal _exercise() function for each vault in a loop. Inside _exercise(), the contract validated msg.value == amtUnderlyingToPay to confirm the user had sent sufficient ETH collateral. Since msg.value is constant for the entire transaction, a single ETH deposit passed the check for every vault in the loop.

The attacker called exercise() with multiple vaults, sending ETH only once but exercising options against each vault, extracting USDC collateral from each. The attacker paid for one exercise but collected payouts from many.

CALLVALUE’s role: The _exercise() function read CALLVALUE to verify payment. Because CALLVALUE returns the same value on every read within a transaction, the single payment was validated N times.

Impact: ~$371,260 in USDC drained from vault owners. Opyn reimbursed affected users.

References:


Exploit 2: SushiSwap MISO Dutch Auction — $350M at Risk (August 2021)

Root cause: msg.value reused across delegatecalls in a batch function (BoringBatchable).

Details: SushiSwap’s MISO platform used BoringBatchable, a library that provides a batch() function executing address(this).delegatecall(calls[i]) for each call in an array. The Dutch auction contract inherited this batch function and had a commitEth() payable function.

An attacker could batch multiple commitEth() calls, each of which saw the same msg.value (preserved by delegatecall). This enabled two attack vectors:

  1. Token drain: Commit more ETH than actually sent, receiving excess auction tokens
  2. ETH drain: If the auction hit its maximum commitment, excess ETH would be refunded. The attacker could claim refunds for the phantom commitments, draining real ETH from the contract

The vulnerability was discovered by samczsun (Paradigm) and disclosed responsibly. It was patched within 5 hours with no funds lost. At the time of discovery, approximately 109,000 ETH ($350M) was at risk.

CALLVALUE’s role: Each delegatecall in the batch loop exposed the same CALLVALUE to commitEth(). The auction contract trusted msg.value as the commitment amount, but the same value was committed repeatedly.

Impact: $350M at risk; no funds lost due to white-hat intervention.

References:


Exploit 3: Multicall msg.value Double-Spend (Recurring Pattern)

Root cause: Any contract combining a multicall/batch pattern with payable delegatecalls.

Details: The Opyn and MISO exploits established msg.value reuse as a known vulnerability class. Since then, the pattern has been found in numerous protocols:

  • Uniswap V3 Multicall: Uniswap’s Multicall contract uses address(this).delegatecall() in a loop. Their documentation explicitly warns that msg.value is passed onto all subcalls. Payable functions in the router must be carefully designed to not double-count msg.value.
  • BoringBatchable pattern: Any contract inheriting BoringBatchable (or similar batch-via-delegatecall libraries) that has payable functions is potentially vulnerable.
  • ERC2771 + Multicall: In December 2023, the combination of ERC2771 meta-transactions with multicall patterns was exploited, affecting NFT Trader (1.5M). While the primary vector was sender spoofing, the multicall batching amplified the attack surface.

Trail of Bits created the msg-value-loop Slither detector specifically for this vulnerability class. It flags any use of msg.value inside a loop or inside a function called via delegatecall in a loop.

References:


Attack Scenarios

Scenario A: msg.value Reuse in Loop (Opyn Pattern)

contract VulnerableOptions {
    mapping(address => uint256) public collateral;
 
    function exerciseMultiple(address[] calldata vaults) external payable {
        for (uint i = 0; i < vaults.length; i++) {
            _exercise(vaults[i]);
        }
    }
 
    function _exercise(address vault) internal {
        // BUG: msg.value is checked N times but ETH was only sent once
        require(msg.value >= EXERCISE_COST, "Insufficient ETH");
 
        collateral[vault] -= EXERCISE_COST;
        payable(msg.sender).transfer(PAYOUT_PER_VAULT);
    }
}
 
// Attack: call exerciseMultiple([vault1, vault2, vault3, ...vault10])
// with msg.value = EXERCISE_COST (just once)
// Result: attacker pays once, collects PAYOUT_PER_VAULT * 10

Scenario B: Delegatecall Multicall Double-Spend (MISO Pattern)

contract VulnerableAuction {
    mapping(address => uint256) public commitments;
    uint256 public totalCommitted;
 
    function commitEth() external payable {
        commitments[msg.sender] += msg.value;
        totalCommitted += msg.value;
    }
 
    // BoringBatchable-style batch function
    function batch(bytes[] calldata calls) external payable {
        for (uint i = 0; i < calls.length; i++) {
            // BUG: delegatecall preserves msg.value for every call
            (bool ok,) = address(this).delegatecall(calls[i]);
            require(ok);
        }
    }
}
 
// Attack: batch([commitEth(), commitEth(), commitEth()])
// with msg.value = 10 ETH
// Result: commitments[attacker] = 30 ETH, but only 10 ETH was sent
// Attacker can later claim refund of 30 ETH

Scenario C: Missing msg.value Validation (Overpayment Lock)

contract VulnerableNFTMint {
    uint256 constant PRICE = 0.1 ether;
 
    function mint(uint256 quantity) external payable {
        require(msg.value >= PRICE * quantity, "Underpaid");
        for (uint i = 0; i < quantity; i++) {
            _safeMint(msg.sender, nextTokenId++);
        }
        // BUG: no refund of msg.value - PRICE * quantity
        // If user sends 1 ETH for a 0.1 ETH mint, 0.9 ETH is locked forever
    }
}

Scenario D: Zero msg.value Bypassing Economic Commitment

contract VulnerableAuction {
    struct Bid {
        address bidder;
        uint256 amount;
    }
    mapping(uint256 => Bid[]) public auctionBids;
 
    function placeBid(uint256 auctionId) external payable {
        // BUG: no minimum value check
        auctionBids[auctionId].push(Bid(msg.sender, msg.value));
        if (msg.value > highestBid[auctionId]) {
            highestBid[auctionId] = msg.value;
            highestBidder[auctionId] = msg.sender;
        }
    }
    // Attack: flood with msg.value = 0 bids to bloat storage and increase gas
    // costs for legitimate bid processing
}

Scenario E: Delegatecall Proxy Forwarding Unexpected Value

contract Proxy {
    address public implementation;
 
    fallback() external payable {
        // Forwards everything including msg.value via delegatecall
        (bool ok,) = implementation.delegatecall(msg.data);
        require(ok);
    }
}
 
contract Implementation {
    function withdraw(uint256 amount) external {
        // Not marked payable, but receives msg.value context via delegatecall
        // If proxy's fallback was called with ETH, msg.value > 0 here
        // This could confuse accounting logic that checks msg.value
        require(msg.value == 0, "No ETH expected");  // Defensive check
        _processWithdraw(msg.sender, amount);
    }
}

Mitigations

ThreatMitigationImplementation
T1: msg.value reuse in loopsTrack total consumed value; compare against msg.value onceuint256 totalUsed; totalUsed += cost; require(totalUsed <= msg.value); after loop
T1: msg.value reuse in multicallRestrict payable functions from batch/multicall, or track ETH accounting per batchRemove payable from functions callable via batch, or use a _ethUsed accumulator
T2: Delegatecall value persistenceNever rely on msg.value inside delegatecall targets for payment verificationUse address(this).balance changes or explicit accounting instead of msg.value
T3: Missing msg.value validationAlways validate msg.value matches expected amount exactlyrequire(msg.value == expectedAmount) with refund for overpayment
T3: Overpayment lockRefund excess ETH after deducting the required amountif (msg.value > cost) payable(msg.sender).transfer(msg.value - cost);
T4: Zero msg.value bypassEnforce minimum value for economic commitmentrequire(msg.value >= MIN_BID, "Below minimum")
T5: Reentrancy via ETH transferChecks-effects-interactions pattern; reentrancy guardsUpdate state before external calls; use ReentrancyGuard

Static Analysis Tooling

ToolDetectorWhat It Catches
Slithermsg-value-loopmsg.value used inside a loop or inside a function called in a loop
Slitherarbitrary-send-ethFunctions that send ETH to arbitrary addresses
MythrilInteger analysisSymbolic execution of msg.value-dependent paths
SemgrepCustom rulesPattern-match msg.value inside for/while loops or delegatecall contexts

Design Patterns for Safe msg.value Handling

// SAFE: Accumulator pattern -- track total ETH used across iterations
function processMultiple(uint256[] calldata amounts) external payable {
    uint256 totalRequired;
    for (uint i = 0; i < amounts.length; i++) {
        totalRequired += amounts[i];
        _processPayment(amounts[i]);
    }
    require(msg.value == totalRequired, "Incorrect ETH amount");
}
 
// SAFE: Wrap-and-unwrap pattern for multicall with ETH
function multicall(bytes[] calldata data) external payable {
    // Wrap all ETH to WETH first, then use token accounting
    if (msg.value > 0) {
        WETH.deposit{value: msg.value}();
    }
    for (uint i = 0; i < data.length; i++) {
        address(this).delegatecall(data[i]);
        // Sub-calls use WETH balances, not msg.value
    }
}

Severity Summary

Threat IDCategorySeverityLikelihoodReal-World Precedent
T1Smart ContractCriticalHighOpyn (350M at risk), recurring pattern
T2Smart ContractHighHighInherent to all delegatecall-based proxies and multicall patterns
T3Smart ContractHighMediumNumerous NFT mints and DeFi protocols with locked ETH
T4Smart ContractMediumMediumAuction griefing, zero-value bid spam
T5Smart ContractMediumMediumClassic reentrancy (The DAO, 2016)
P1ProtocolLowN/A
P2ProtocolLowN/A
P3ProtocolLowLowEdge case in staticcall-based view functions
P4ProtocolInformationalN/AEIP-7069 does not fix msg.value reuse

OpcodeRelationship
CALLER (0x33)Returns msg.sender; like CALLVALUE, persists across delegatecall. Both create context-confusion bugs in proxy patterns
CALL (0xF1)Initiates an external call with an explicit value parameter. The value becomes the callee’s CALLVALUE
DELEGATECALL (0xF4)Executes code in caller’s context; does not accept a value parameter. Preserves the original msg.value, enabling reuse
STATICCALL (0xFA)Read-only call; CALLVALUE always returns 0 inside a staticcall since value transfer is prohibited
CALLCODE (0xF2)Deprecated predecessor to DELEGATECALL; also preserves msg.value context
SELFBALANCE (0x47)Returns contract’s ETH balance; use balance-difference tracking instead of msg.value for accounting in delegatecall contexts
ORIGIN (0x32)Returns tx.origin; another context value that persists across all call types (but has different threat profile)