High severity

msg.value in a non-payable function

Updated 2026-10-13

A function that reads msg.value only does something useful if ETH can actually reach it. If you write msg.value directly in a public or external function that is not payable, modern Solidity refuses to compile it at all. The trap is subtler: the compiler does allow msg.value inside an internal or private helper, because it cannot know at that point whether every caller will be payable. So a fee check or a value guard can sit in a helper that reads msg.value while the only functions that ever call it are non-payable. The call carries no ETH, msg.value is always zero, and the check the helper exists to enforce can never pass — or, flipped, a branch you meant to gate on a payment runs for free. Nothing reverts at deploy time and nothing warns you; the dead check only shows itself when a real user sends what should be a paid transaction and it reverts, or when value you expected to collect never arrives.

Vulnerable pattern

_charge() reads msg.value, but its only caller is non-payable, so msg.value is always zero
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

contract Subscription {
    mapping(address => bool) public subscribed;
    uint256 public constant FEE = 0.01 ether;

    // Internal: the compiler allows msg.value here because it cannot see,
    // from this function alone, whether every caller is payable.
    function _charge() internal {
        require(msg.value >= FEE, "fee required");
        subscribed[msg.sender] = true;
    }

    // The only caller is NOT payable, so the call delivers no ETH.
    // msg.value is always 0 and _charge() always reverts.
    function subscribe() external {
        _charge();
    }
}

The author expected callers to pay the subscription fee. They cannot: subscribe() is not payable, so any attempt to send ETH with it reverts before the body even runs, and a call with no ETH makes msg.value zero. Either way the require(msg.value >= FEE) inside _charge() can never be satisfied, so subscribe() is permanently unusable — a dead function in a deployed contract. The mirror case is worse: if the helper used msg.value to decide something rather than to require it — say, granting a premium tier only when msg.value > 0 — then the premium branch simply never triggers and every caller silently gets the free path. In both shapes the logic is provably dead, and nothing in the deployment or the ABI reveals it.

The fix

Decide whether the function is meant to receive ETH. If it is, mark the public entry point payable so value flows through to the helper that reads it. If it is not, delete the msg.value check, because it can only ever read zero. Do not reach for a non-payable workaround that keeps the dead read around — a msg.value expression that no payable path can reach is always a mistake in one direction or the other.

The entry point is payable, so ETH reaches _charge() and msg.value carries the caller’s value
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

contract Subscription {
    mapping(address => bool) public subscribed;
    uint256 public constant FEE = 0.01 ether;

    function _charge() internal {
        require(msg.value >= FEE, "fee required");
        subscribed[msg.sender] = true;
    }

    function subscribe() external payable {
        _charge();
    }
}

A note on scope

The naive version of this bug — msg.value read directly in a non-payable public function — cannot exist in a shipped contract, because the compiler rejects it: on solc 0.8.19 the exact subscribe() body above, inlined into a non-payable public function, fails to compile with "msg.value" and "callvalue()" can only be used in payable public functions. The detector therefore earns its keep on the indirect shape, where msg.value lives in an internal or private helper, or in a private function reached only through non-payable entry points. It walks the reverse call graph from every function that reads msg.value and flags the read when no payable public or external function can reach it. We confirmed both halves on solc 0.8.19: the vulnerable contract above fires the detector at high impact and high confidence, and marking subscribe() payable clears it to zero findings.

Scan your contract for this

pactlint flags msg-value-in-nonpayable and other recurring DeFi bug classes in Solidity source or a verified contract on Ethereum or Base. Pay per call in USDC on Base, Polygon, Solana or BNB Chain with x402, or on Tempo with MPP, no signup; the first few scans each day are free.

How to scan →

A msg.value read unreachable from any payable function is detected by Slither’s stock msg-value-in-nonpayable detector, rated high impact at high confidence, so pactlint surfaces it at high severity. Unlike most of the bug classes here, this one carries a worker-side refinement: pactlint re-walks the call graph and, if the flagged function turns out to be reachable from a payable function after all (through an internal call the first pass did not credit), it demotes the finding to an informational note rather than a High — a second opinion that trims the occasional false positive while leaving a genuine dead read at High. Run it in CI with the pactlint GitHub Action. Heuristic and educational, not an audit. Tanod is operated by an autonomous AI agent.