Opcode Summary

PropertyValue
Opcode0x19
MnemonicNOT
Gas3
Stack Inputa
Stack Output~a
BehaviorBitwise complement of a 256-bit value. Every bit is flipped: 0 becomes 1, 1 becomes 0. Equivalent to a ^ MAX_UINT256 or MAX_UINT256 - a.

Threat Surface

NOT is the EVM’s bitwise complement operator, and its threat surface is dominated by a single, deceptively dangerous confusion: NOT is not logical negation. In the EVM:

  • NOT(0) returns 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF (MAX_UINT256), not 1
  • NOT(1) returns MAX_UINT256 - 1, not 0
  • ISZERO(0) returns 1 (logical negation)
  • ISZERO(1) returns 0 (logical negation)

This distinction has caused real vulnerabilities where developers used not() in Yul/assembly code expecting boolean negation and got bitwise complement instead. The TypedMemView library bug in Nomad’s infrastructure is the canonical example: not(gt(a, b)) was used expecting a boolean result, but it returned MAX_UINT256 or MAX_UINT256 - 1, both of which are truthy, making the condition always pass.

The secondary threat surface involves NOT’s relationship to two’s complement negation. In two’s complement, -a == NOT(a) + 1. This means NOT is one step away from arithmetic negation, and off-by-one errors between NOT(x) and -x (i.e., NOT(x) + 1) are a recurring bug class. The edge case where NOT(0) + 1 overflows to 0 (instead of producing -0, which doesn’t exist in two’s complement) adds to the confusion.


Smart Contract Threats

T1: NOT vs ISZERO Confusion in Assembly (Critical)

The most dangerous NOT vulnerability: using not() where iszero() is intended in Yul or inline assembly. Since Yul has no boolean type — all values are 256-bit words — the distinction between “bitwise complement” and “logical negation” must be manually maintained. The TypedMemView library contained exactly this bug:

// Vulnerable: not() returns MAX_UINT256 or MAX_UINT256-1, both truthy
function isValid(ptr) -> result {
    result := not(gt(end, mload(0x40)))
}
// gt() returns 0 or 1
// not(0) = MAX_UINT256 (truthy) -- WRONG, should be 1
// not(1) = MAX_UINT256 - 1 (truthy) -- WRONG, should be 0
// Result: isValid() ALWAYS returns true
 
// Fixed: iszero() returns 0 or 1, proper boolean negation
function isValid(ptr) -> result {
    result := iszero(gt(end, mload(0x40)))
}

Why it matters: This confusion is easy to introduce and hard to catch. not reads as “NOT” in English, which suggests logical negation. Auditors unfamiliar with Yul semantics may read not(gt(a, b)) as “not greater than” and assume it returns a boolean. The bug is invisible to tests with inputs that always satisfy the condition, since the function returns a truthy value either way.

T2: Two’s Complement Negation Off-by-One (High)

The relationship NEG(a) == NOT(a) + 1 means using NOT alone for arithmetic negation produces an off-by-one error:

// Want: -x (arithmetic negation)
// Got: ~x (bitwise complement) = -x - 1
 
// In assembly:
let neg_x := not(x)       // Off by one! This is -(x+1), not -x
let neg_x := add(not(x), 1)  // Correct negation
 
// Edge case: not(0) + 1 = MAX_UINT256 + 1 = 0 (overflow)
// This is correct: -0 = 0 in two's complement
// But: not(MAX_UINT256) + 1 = 0 + 1 = 1
// This is wrong: -(MAX_UINT256) should be 1 in two's complement... actually it IS 1
// Because MAX_UINT256 in two's complement signed is -1, and -(-1) = 1

The FuzzingLabs audit of LambdaClass’s EVM implementation found this exact edge case in the negate() function for SDIV: !value + U256::one() panics when value == U256::MAX because !U256::MAX = 0 and 0 + 1 = 1, but the intermediate !value step on certain implementations can trigger overflow in the addition.

T3: MAX_UINT256 Generation and Approval Patterns (Medium)

NOT(0) is the cheapest way to generate MAX_UINT256 (type(uint256).max) in EVM bytecode. This value is commonly used for:

  • Infinite approvals: token.approve(spender, type(uint256).max) — the standard “approve max” pattern
  • Sentinel values: Using MAX_UINT256 as “not set” or “infinity”
  • Bitmask generation: NOT(0) as the all-ones mask

The security concern is not NOT itself, but that contracts frequently treat MAX_UINT256 as a special value (e.g., “don’t decrement allowance if it’s max”). If NOT is used to generate this value but the subsequent comparison uses a slightly different constant (e.g., 2^256 - 1 computed differently), the comparison can fail.

T4: Complement Masking Errors (Medium)

NOT is used to create inverted masks for clearing bit fields: packed & ~MASK clears the bits in MASK. If the mask is constructed incorrectly, NOT amplifies the error:

// Intended: clear the lower 160 bits
uint256 mask = type(uint160).max;           // 0x00...00FFFF...FFFF (160 ones)
uint256 cleared = packed & ~mask;           // Correct: clears lower 160 bits
 
// Bug: mask is 128 bits instead of 160
uint256 bugMask = type(uint128).max;        // 0x00...00FFFF...FFFF (128 ones)
uint256 bugCleared = packed & ~bugMask;     // Clears only 128 bits, leaves 32 bits of old data

T5: NOT in Signed Integer Overflow Edge Cases (Medium)

For int256, the minimum value type(int256).min (-2^255) cannot be safely negated because NOT(MIN_INT256) + 1 = MAX_INT256 + 1, which overflows. Solidity’s unchecked { -x } on type(int256).min produces type(int256).min (unchanged), which is a known edge case that contracts must handle.


Protocol-Level Threats

P1: No DoS Vector (Low)

NOT costs a fixed 3 gas with no dynamic component. It operates on a single stack element.

P2: Consensus Safety (Low)

NOT is trivially deterministic: each output bit is the complement of the input bit. All EVM implementations agree. No consensus bugs have occurred due to NOT.

P3: EVM Implementation Negate Bugs (Medium)

FuzzingLabs discovered that the negate() function (which uses NOT internally: !value + 1) in LambdaClass’s EVM implementation panics on U256::MAX input. While this is an implementation bug rather than a protocol bug, it demonstrates that NOT-based arithmetic in EVM clients can have correctness issues at boundary values. A consensus-critical client with this bug would halt on specially crafted transactions.


Edge Cases

Edge CaseBehaviorSecurity Implication
NOT(0)Returns MAX_UINT256Used for max approvals and sentinel values
NOT(MAX_UINT256)Returns 0Complement of all-ones is zero
NOT(1)Returns MAX_UINT256 - 1NOT does NOT return 0 for input 1 (not boolean negation)
NOT(NOT(x))Returns xDouble complement is identity
NOT(x) + 1Returns -x (two’s complement)Arithmetic negation; overflows when x = 0 (wraps to 0, which is correct)
NOT(x) + 1 where x = 2^255Returns 2^255MIN_INT256 negation returns itself (overflow)
NOT(x) used as booleanAlways truthy (unless x = MAX_UINT256)Causes always-true conditions in Yul
AND(x, NOT(mask))Clears bits in mask positionComplement masking; error in mask amplified by NOT

Real-World Exploits

Exploit 1: TypedMemView NOT/ISZERO Confusion — Nomad Infrastructure (February 2023)

Root cause: The isValid() function in the TypedMemView library used not() instead of iszero() in Yul assembly, causing it to always return a truthy value regardless of input.

Details: TypedMemView is a Solidity library for safe memory manipulation, used by the Nomad bridge team. The isValid() function was supposed to check whether a memory pointer was within valid bounds by comparing the end of the pointed-to data against the free memory pointer. The implementation used:

result := not(gt(end, mload(0x40)))

The gt() function returns 0 or 1. not(0) returns MAX_UINT256 and not(1) returns MAX_UINT256 - 1. Both are non-zero, so both are truthy. The function therefore returned “valid” for all inputs, completely bypassing the bounds check.

NOT’s role: NOT was used where ISZERO was needed. The fix was a single-word change: notiszero. This is the canonical example of the NOT/ISZERO confusion vulnerability.

Impact: The bug was reported as low severity because it was in a safety check (bounds validation) rather than a direct fund-handling path. However, it broke a fundamental memory safety guarantee of the library. The bug was disclosed to Nomad via Immunefi on February 15, 2023, and fixed by February 23, 2023.

Note: This is a separate vulnerability from the $190M Nomad bridge hack (August 2022), which was caused by an uninitialized trusted root in the Replica contract.

References:


Exploit 2: LambdaClass EVM Negate Overflow — DoS via SDIV (2024)

Root cause: The negate() function in LambdaClass’s EVM implementation used !value + U256::one() (NOT plus 1) without handling the edge case where the addition overflows.

Details: During a security audit by FuzzingLabs, a bug was found in the signed division (SDIV) implementation. SDIV handles negative operands by negating them (making them positive), performing unsigned division, then negating the result. The negation used NOT(value) + 1 (the standard two’s complement formula). When value == U256::MAX, NOT(U256::MAX) == 0, and 0 + 1 == 1, which is correct. But when value == U256::MAX in certain internal representations, the implementation could panic due to overflow in the addition step.

An attacker could trigger this with a crafted SDIV instruction in a transaction, causing the EVM node to crash. In a consensus-critical context, this represents a denial-of-service vector against individual nodes.

NOT’s role: NOT was the first step in the two’s complement negation. The bug was in the interaction between NOT and the subsequent ADD, where the implementation didn’t handle the edge case correctly.

References:


Root cause: Solidity versions 0.6.5 through 0.8.8 had a bug where signed immutable variables shorter than 256 bits were not properly sign-extended when read, causing NOT-based operations on them to produce incorrect results.

Details: When a signed integer type shorter than 32 bytes (e.g., int8, int128) is stored as an immutable, the compiler must sign-extend it to 256 bits when loading. Versions 0.6.5–0.8.8 failed to perform this extension. A value like int8(-2) was stored as 0x00...00FE instead of 0xFF...FFFE. Operations that depend on the sign bits — including NOT, comparison, and arithmetic — would produce incorrect results.

For NOT specifically: NOT(0x00...00FE) = 0xFF...FF01, which represents -255 in int256. The correct behavior is NOT(0xFF...FFFE) = 0x00...0001, which represents 1. The difference is catastrophic for any signed arithmetic.

NOT’s role: NOT on a sign-extension-corrupted value produces a value with the wrong sign and magnitude. Any contract using NOT on signed immutables in the affected compiler range is vulnerable.

References:


Attack Scenarios

Scenario A: Always-True Guard via NOT/ISZERO Confusion

contract VulnerableMemory {
    function processData(bytes memory data) external {
        bool valid;
        assembly {
            let end := add(data, mload(data))
            let freePtr := mload(0x40)
            // Bug: not() instead of iszero() -- always truthy
            valid := not(gt(end, freePtr))
        }
        require(valid, "invalid memory");  // Always passes!
        // Process potentially out-of-bounds data...
    }
}

Scenario B: Off-by-One Negation in Price Calculation

contract VulnerableOracle {
    function invertPrice(int256 price) external pure returns (int256) {
        int256 result;
        assembly {
            // Bug: NOT alone is off-by-one from negation
            result := not(price)  // Returns -(price+1), not -price
            // For price = 100: returns -101, not -100
        }
        return result;
    }
    
    // Correct:
    // result := add(not(price), 1)  // Two's complement negation
    // Or use Solidity: result = -price;
}

Scenario C: Complement Mask Width Error

contract PackedStorage {
    uint256 constant ADDR_MASK = type(uint128).max;  // Bug: should be uint160
    
    function clearAddress(uint256 packed) internal pure returns (uint256) {
        // NOT(128-bit mask) preserves 128 bits, not 160
        // 32 bits of the old address leak through
        return packed & ~ADDR_MASK;
    }
}

Scenario D: MAX_UINT256 Allowance Special Case

contract VulnerableToken {
    function transferFrom(address from, address to, uint256 amount) external {
        uint256 allowed = allowance[from][msg.sender];
        
        // Common pattern: don't decrement infinite allowance
        if (allowed != type(uint256).max) {
            allowance[from][msg.sender] = allowed - amount;
        }
        
        // Bug: if NOT(0) was used to set the allowance in a different code path
        // and it computed MAX_UINT256 - 1 instead of MAX_UINT256 due to an error,
        // the allowance WILL be decremented, potentially underflowing
    }
}

Mitigations

ThreatMitigationImplementation
T1: NOT/ISZERO confusionUse iszero() for boolean negation in Yul; never use not() for booleansLinter rule: flag not(lt/gt/eq/slt/sgt(...)) patterns
T1: Assembly reviewTreat all not() in Yul as suspect during auditSearch pattern: not( in assembly blocks
T2: Negation off-by-oneUse sub(0, x) or add(not(x), 1) for arithmetic negation in assemblyDocument the pattern clearly; test with edge values
T2: MIN_INT edge caseCheck for MIN_INT before negationrequire(x != type(int256).min) or handle specially
T3: MAX_UINT generationUse type(uint256).max in Solidity instead of ~uint256(0)Explicit named constant; avoid assembly not(0) where Solidity suffices
T4: Mask errorsDefine masks from type sizes: (1 << WIDTH) - 1Named constants with documented bit ranges
T5: Signed overflowUse Solidity’s checked arithmetic for signed negationAvoid unchecked { -x } on user-controlled signed values

Detection Strategies

  • Automated detection: Custom Slither detectors should flag not(gt(...)), not(lt(...)), not(eq(...)) in inline assembly as likely NOT/ISZERO confusion.
  • Compiler version check: Contracts using signed immutables compiled with Solidity 0.6.5–0.8.8 should be reviewed for sign extension bugs.
  • Fuzz testing: Test NOT-based operations with 0, 1, MAX_UINT256, 2^255, and 2^255 - 1 as inputs.

Severity Summary

Threat IDCategorySeverityLikelihoodReal-World Precedent
T1Smart ContractCriticalMediumTypedMemView / Nomad (2023)
T2Smart ContractHighMediumLambdaClass EVM negate bug
T3Smart ContractMediumLowMAX_UINT256 allowance patterns
T4Smart ContractMediumMediumComplement mask width errors in packed storage
T5Smart ContractMediumLowSigned immutables bug (Solidity 0.6.5–0.8.8)
P1ProtocolLowN/A
P2ProtocolLowN/A
P3ProtocolMediumLowLambdaClass EVM crash via SDIV negate

OpcodeRelationship
ISZERO (0x15)Logical negation (returns 0 or 1). The correct choice when NOT is mistakenly used for boolean logic
AND (0x16)AND(x, NOT(mask)) clears bits in mask; NOT is used to invert masks for clearing
XOR (0x18)XOR(x, MAX_UINT256) is equivalent to NOT(x) — both flip all bits
SUB (0x03)SUB(0, x) produces arithmetic negation (two’s complement); equivalent to NOT(x) + 1
ADD (0x01)ADD(NOT(x), 1) is two’s complement negation; the NOT+ADD pattern
SIGNEXTEND (0x0B)Required for correct NOT behavior on sub-256-bit signed values; the signed immutables bug was a missing SIGNEXTEND
SDIV (0x05)Uses NOT internally for negation of signed operands; the negate overflow bug affects SDIV