Opcode Summary
| Property | Value |
|---|---|
| Opcode | 0x19 |
| Mnemonic | NOT |
| Gas | 3 |
| Stack Input | a |
| Stack Output | ~a |
| Behavior | Bitwise 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)returns0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF(MAX_UINT256), not1NOT(1)returnsMAX_UINT256 - 1, not0ISZERO(0)returns1(logical negation)ISZERO(1)returns0(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) = 1The 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 dataT5: 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 Case | Behavior | Security Implication |
|---|---|---|
NOT(0) | Returns MAX_UINT256 | Used for max approvals and sentinel values |
NOT(MAX_UINT256) | Returns 0 | Complement of all-ones is zero |
NOT(1) | Returns MAX_UINT256 - 1 | NOT does NOT return 0 for input 1 (not boolean negation) |
NOT(NOT(x)) | Returns x | Double complement is identity |
NOT(x) + 1 | Returns -x (two’s complement) | Arithmetic negation; overflows when x = 0 (wraps to 0, which is correct) |
NOT(x) + 1 where x = 2^255 | Returns 2^255 | MIN_INT256 negation returns itself (overflow) |
NOT(x) used as boolean | Always truthy (unless x = MAX_UINT256) | Causes always-true conditions in Yul |
AND(x, NOT(mask)) | Clears bits in mask position | Complement 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: not → iszero. 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:
Exploit 3: Solidity Signed Immutables Bug — NOT-Related Sign Extension (September 2021)
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
| Threat | Mitigation | Implementation |
|---|---|---|
| T1: NOT/ISZERO confusion | Use iszero() for boolean negation in Yul; never use not() for booleans | Linter rule: flag not(lt/gt/eq/slt/sgt(...)) patterns |
| T1: Assembly review | Treat all not() in Yul as suspect during audit | Search pattern: not( in assembly blocks |
| T2: Negation off-by-one | Use sub(0, x) or add(not(x), 1) for arithmetic negation in assembly | Document the pattern clearly; test with edge values |
| T2: MIN_INT edge case | Check for MIN_INT before negation | require(x != type(int256).min) or handle specially |
| T3: MAX_UINT generation | Use type(uint256).max in Solidity instead of ~uint256(0) | Explicit named constant; avoid assembly not(0) where Solidity suffices |
| T4: Mask errors | Define masks from type sizes: (1 << WIDTH) - 1 | Named constants with documented bit ranges |
| T5: Signed overflow | Use Solidity’s checked arithmetic for signed negation | Avoid 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, and2^255 - 1as inputs.
Severity Summary
| Threat ID | Category | Severity | Likelihood | Real-World Precedent |
|---|---|---|---|---|
| T1 | Smart Contract | Critical | Medium | TypedMemView / Nomad (2023) |
| T2 | Smart Contract | High | Medium | LambdaClass EVM negate bug |
| T3 | Smart Contract | Medium | Low | MAX_UINT256 allowance patterns |
| T4 | Smart Contract | Medium | Medium | Complement mask width errors in packed storage |
| T5 | Smart Contract | Medium | Low | Signed immutables bug (Solidity 0.6.5–0.8.8) |
| P1 | Protocol | Low | N/A | — |
| P2 | Protocol | Low | N/A | — |
| P3 | Protocol | Medium | Low | LambdaClass EVM crash via SDIV negate |
Related Opcodes
| Opcode | Relationship |
|---|---|
| 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 |