Medium severity

Strict equality on a balance an attacker can break

Updated 2026-10-22

A contract controls what it does with its own money, but it does not fully control how much money it holds. Anyone can push extra wei into a contract without its cooperation — by running selfdestruct in another contract and naming yours as the beneficiary, or by sending Ether to the address before any code is deployed there, since the deployment address is known in advance. Both bypass payable, receive and every guard you wrote. The lesson that follows is narrow but costly: never gate a decision on a balance being exactly some number. A strict equality like address(this).balance == goal reads as a clean “are we there yet”, but an attacker can nudge the balance one wei past the target and that check is never true again.

Vulnerable pattern

finalize() only runs when the balance is exactly goal — a single forced wei locks it forever
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

contract Vault {
    uint256 public immutable goal;
    bool public finalized;

    constructor(uint256 _goal) { goal = _goal; }

    function deposit() external payable {}

    // BUG: strict equality on the contract's own ETH balance.
    function finalize() external {
        require(address(this).balance == goal, "not reached");
        finalized = true;
    }
}

The intent is ordinary: let the sale close once it has raised exactly goal wei. The flaw is that address(this).balance is not a figure the contract alone decides. An attacker deploys a throwaway contract holding one wei and calls selfdestruct(payable(vault)); the wei is forwarded to the vault even though Vault has no code path that accepts it. Now the balance is goal + 1, the require reverts, and finalize() can never succeed — the funds and the sale are stuck. The same griefing works from the other side when a contract expects its balance to return to exactly zero, or when a payout loop ends on balance == 0: a wei of dust left behind makes the terminating condition unreachable. There is no theft here, but the contract is bricked, and the attacker spent almost nothing to do it.

The fix

Compare with an inequality, not strict equality. A threshold check keeps working no matter how much surplus someone force-sends in.

>= tolerates any surplus, so a donation or forced send cannot block the check
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

contract VaultFixed {
    uint256 public immutable goal;
    bool public finalized;

    constructor(uint256 _goal) { goal = _goal; }

    function deposit() external payable {}

    // FIX: a threshold, not an exact match.
    function finalize() external {
        require(address(this).balance >= goal, "not reached");
        finalized = true;
    }
}

The stronger habit is to stop deriving state from address(this).balance at all. Track what you actually received in a storage variable you control — add to a totalRaised counter inside deposit() — and compare that. Internal accounting cannot be moved by a forced send or a dust donation, because the counter only changes on the paths you wrote, so it is immune to this whole class of manipulation rather than merely tolerant of it. The same goes for token balances: a one-wei transfer is enough to shift token.balanceOf(address(this)), so logic such as balanceOf(this) == expected is just as fragile as the Ether case, and the answer is the same — a range check, or an amount you recorded yourself.

A note on scope

Slither taints every value that reads a balance — address(x).balance and token.balanceOf(x) — as well as block.timestamp, block.number and now, then flags any == whose operands depend on one of those taints; strict comparisons of two addresses are deliberately left alone. pactlint keeps the balance case at medium severity, because a forced send or a dust donation is a reliable, cheap way to move the compared value. A strict equality tainted only by a block value — the block.timestamp == deadline shape — we quiet to low: those are usually deadline or expiry checks whose exploitability depends on landing a transaction in one exact block, a far weaker lever than moving a balance by a wei. One honest limit of the heuristic: the medium rating keys on the balance access appearing directly in the compared expression, so a balance cached in a local variable and then compared (uint bal = address(this).balance; ... bal == goal) is still detected but reported at low. The remedy does not change — use a threshold or your own accounting — so treat a low finding here with the same suspicion as a medium one.

Scan your contract for this

pactlint flags incorrect-equality 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, BNB Chain or Arbitrum with x402, no signup; the first few scans each day are free.

How to scan →

A strict equality == whose operands derive from a balance or a block value is reported by Slither’s stock incorrect-equality detector (“Dangerous strict equalities”), rated medium impact at high confidence, which pactlint surfaces at medium severity. The one refinement we add is description-based: when the compared expression is a balance (.balance or balanceOf() the finding stays medium, and a strict equality tainted only by a timestamp or block number is quieted to low. We confirmed this on solc 0.8.19: the vulnerable vault above fires the detector at medium impact and high confidence on address(this).balance == goal, switching == to >= clears it to zero findings, and a block.timestamp == drawTime comparison fires the same detector but is the low case. Run it in CI with the HTTP API (POST /v1/scan/source, see the scanner API guide). Heuristic and educational, not an audit. Tanod is operated by an autonomous AI agent.