Opcode Summary
| Property | Value |
|---|---|
| Opcode | 0x34 |
| Mnemonic | CALLVALUE |
| Gas | 2 |
| Stack Input | (none) |
| Stack Output | msg.value |
| Behavior | Pushes 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:
-
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.
-
msg.value can be reused in loops. Even without delegatecall, a payable function that iterates over multiple items and checks
msg.valuein 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). -
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.valueBoth 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
payablefor gas savings but never uses msg.value. Sent ETH is locked permanently. - Loose check: Function checks
msg.value > 0but 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)whenexpectedAmountis 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 Case | Behavior | Security Implication |
|---|---|---|
| msg.value in DELEGATECALL | Returns the original caller’s msg.value, not 0 | Enables msg.value reuse across batched delegatecalls |
| msg.value in STATICCALL | Always returns 0 (value transfer prohibited) | Contracts using msg.value > 0 as a guard will always fail under staticcall |
| msg.value == 0 in payable function | Function executes normally; no ETH transferred | Zero-value calls bypass economic commitment checks |
| msg.value in multicall (delegatecall loop) | Same msg.value available in every sub-call | Double/triple/N-spending of a single ETH deposit |
| msg.value in CALLCODE | Preserves original msg.value (like DELEGATECALL) | Same reuse risk; CALLCODE is deprecated but still functional |
| msg.value with CREATE/CREATE2 | CALLVALUE in constructor returns the value sent to CREATE | Constructor can receive ETH; no reuse risk (single execution) |
| msg.value > address(this).balance at call time | Impossible; EVM enforces balance >= value before execution | No underflow risk at the opcode level |
| msg.value persistence after internal function call | msg.value unchanged throughout the entire external call | Internal 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:
- Opyn Post Mortem
- PeckShield Root Cause Analysis
- Trail of Bits: Detecting msg.value Reuse with Slither
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:
- Token drain: Commit more ETH than actually sent, receiving excess auction tokens
- 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 * 10Scenario 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 ETHScenario 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
| Threat | Mitigation | Implementation |
|---|---|---|
| T1: msg.value reuse in loops | Track total consumed value; compare against msg.value once | uint256 totalUsed; totalUsed += cost; require(totalUsed <= msg.value); after loop |
| T1: msg.value reuse in multicall | Restrict payable functions from batch/multicall, or track ETH accounting per batch | Remove payable from functions callable via batch, or use a _ethUsed accumulator |
| T2: Delegatecall value persistence | Never rely on msg.value inside delegatecall targets for payment verification | Use address(this).balance changes or explicit accounting instead of msg.value |
| T3: Missing msg.value validation | Always validate msg.value matches expected amount exactly | require(msg.value == expectedAmount) with refund for overpayment |
| T3: Overpayment lock | Refund excess ETH after deducting the required amount | if (msg.value > cost) payable(msg.sender).transfer(msg.value - cost); |
| T4: Zero msg.value bypass | Enforce minimum value for economic commitment | require(msg.value >= MIN_BID, "Below minimum") |
| T5: Reentrancy via ETH transfer | Checks-effects-interactions pattern; reentrancy guards | Update state before external calls; use ReentrancyGuard |
Static Analysis Tooling
| Tool | Detector | What It Catches |
|---|---|---|
| Slither | msg-value-loop | msg.value used inside a loop or inside a function called in a loop |
| Slither | arbitrary-send-eth | Functions that send ETH to arbitrary addresses |
| Mythril | Integer analysis | Symbolic execution of msg.value-dependent paths |
| Semgrep | Custom rules | Pattern-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 ID | Category | Severity | Likelihood | Real-World Precedent |
|---|---|---|---|---|
| T1 | Smart Contract | Critical | High | Opyn (350M at risk), recurring pattern |
| T2 | Smart Contract | High | High | Inherent to all delegatecall-based proxies and multicall patterns |
| T3 | Smart Contract | High | Medium | Numerous NFT mints and DeFi protocols with locked ETH |
| T4 | Smart Contract | Medium | Medium | Auction griefing, zero-value bid spam |
| T5 | Smart Contract | Medium | Medium | Classic reentrancy (The DAO, 2016) |
| P1 | Protocol | Low | N/A | — |
| P2 | Protocol | Low | N/A | — |
| P3 | Protocol | Low | Low | Edge case in staticcall-based view functions |
| P4 | Protocol | Informational | N/A | EIP-7069 does not fix msg.value reuse |
Related Opcodes
| Opcode | Relationship |
|---|---|
| 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) |