Opcode Summary
| Property | Value |
|---|---|
| Opcode | 0x12 |
| Mnemonic | SLT |
| Gas | 3 |
| Stack Input | a, b |
| Stack Output | a < b (1 if true, 0 if false), signed comparison |
| Behavior | Signed 256-bit less-than comparison using two’s complement. Interprets both operands as signed integers in the range [-2^255, 2^255 - 1]. Returns 1 or 0. |
Threat Surface
SLT is the signed counterpart to LT. While LT treats all 256-bit values as unsigned integers in [0, 2^256 - 1], SLT interprets them as two’s complement signed integers in [-2^255, 2^255 - 1]. The boundary between “positive” and “negative” falls at 2^255:
- Values
0x00...00to0x7FFF...FFFF(0 to 2^255 - 1) are positive - Values
0x8000...0000to0xFFFF...FFFF(2^255 to 2^256 - 1 unsigned) are negative (-2^255 to -1)
The threat surface centers on the signed/unsigned interpretation boundary:
-
Using SLT on unsigned values: If a value is logically unsigned (e.g., a token balance, array index, or gas amount) but compared with SLT, any value >= 2^255 is treated as negative. A balance of
2^255(a legitimate large unsigned value) is interpreted as the most negative possible number (-2^255), completely inverting the comparison. -
Two’s complement boundary exploitation: The transition from
0x7FFF...FFFF(max positive: 2^255 - 1) to0x8000...0000(min negative: -2^255) is a single increment. Arithmetic that pushes a signed value across this boundary causes a sign flip without any overflow exception, potentially inverting all downstream comparison logic. -
Cross-domain confusion: When signed values from one context (e.g., a price delta from an oracle) interact with unsigned values from another context (e.g., a token amount), using the wrong comparison opcode produces silently incorrect results.
Smart Contract Threats
T1: SLT on Unsigned Values — Large Balances Appear Negative (Critical)
When SLT is used to compare values that are logically unsigned, any value with the high bit set (>= 2^255) is treated as negative. This effectively halves the usable unsigned range and inverts comparisons for large values:
- A token with total supply > 2^255 (possible with low-decimal tokens or rebasing tokens) would have balances that SLT considers negative
SLT(2^255, 100)returns1(true): SLT says “negative number < 100”LT(2^255, 100)returns0(false): LT correctly says “large number >= 100”
In assembly or Yul, accidentally using slt instead of lt for unsigned comparisons is a critical bug:
// BUG: slt treats largeBalance as negative
if slt(balance, minRequired) { revert(0, 0) }
// balance = 2^255 (valid uint256) is treated as -2^255, "less than" minRequired
// Transaction reverts despite having more than enough balanceT2: Two’s Complement Sign Boundary Overflow (Critical)
Signed integer overflow in two’s complement occurs when arithmetic crosses the boundary between 2^255 - 1 (max positive) and -2^255 (min negative). The EVM provides no signed overflow detection — ADD, SUB, and MUL all operate on the raw 256-bit representation regardless of signed interpretation:
(2^255 - 1) + 1 = 2^255in unsigned math, which is-2^255in signed- A positive value plus a positive value produces a negative result
- SLT-based checks after such overflow produce inverted results
This matters in:
- Accumulation of signed values: Summing positive deltas that push past
2^255 - 1 - Negation overflow:
int256(-2^255)cannot be negated because2^255doesn’t fit in int256. The expression0 - (-2^255)overflows back to-2^255 - SDIV edge case:
SDIV(-2^255, -1)overflows because2^255exceedsint256.max
T3: Signed/Unsigned Mismatch in Cross-Contract Calls (High)
When Contract A encodes a value as int256 and Contract B decodes it as uint256 (or vice versa), subsequent comparisons using SLT or LT produce contradictory results:
- Contract A sends
int256(-50)(encoded as0xFFFF...FFCE) - Contract B decodes it as
uint256(0xFFFF...FFCE)= a very large positive number - Contract B uses
LT(value, threshold)— the “negative” value appears enormous and passes upper-bound checks - Or: Contract A sends
uint256(2^255 + 100)which Contract B decodes asint256(-2^255 + 100)= a very negative number
T4: Ordering Inconsistency Between Signed and Unsigned (High)
The total ordering defined by SLT differs from LT for any pair where one or both values have the high bit set:
| Values | LT Result | SLT Result | Comment |
|---|---|---|---|
(0, 1) | 1 (true) | 1 (true) | Agree for small positives |
(MAX_UINT256, 0) | 0 (false) | 1 (true) | LT: huge > 0. SLT: -1 < 0 |
(2^255, 2^255 - 1) | 0 (false) | 1 (true) | LT: bigger. SLT: negative < positive |
Using SLT for sorting, indexing, or binary search on unsigned values produces a corrupted ordering where values above 2^255 are sorted before 0.
T5: Conditional Logic Inversion in Assembly (Medium)
Assembly-heavy contracts (optimized DEXs, precompile wrappers, custom data structures) frequently use comparison opcodes directly. Using slt where lt was intended (or vice versa) in conditional jumps silently inverts the logic for a subset of inputs — specifically, any input with the high bit set. This is particularly insidious because it may pass all tests with “normal” small values and only fail on edge-case large values.
Protocol-Level Threats
P1: No DoS Vector (Low)
SLT costs a fixed 3 gas with no dynamic component. Purely stack-based.
P2: Consensus Safety (Low)
Signed comparison on two’s complement 256-bit integers is well-defined and deterministic. All EVM implementations agree on SLT’s behavior. The two’s complement interpretation is specified in the Yellow Paper.
P3: No State Impact (None)
SLT modifies only the stack.
P4: Signed Overflow Detection Gap (Medium)
The EVM provides no native signed overflow detection opcode. Detecting signed overflow after ADD requires checking whether the sign of the result is inconsistent with the signs of the operands:
// Signed overflow: both positive but result negative, or both negative but result positive
// (a > 0 && b > 0 && result < 0) || (a < 0 && b < 0 && result > 0)
This check requires multiple SLT/SGT operations, making it more expensive and error-prone than unsigned overflow detection (a single LT). Protocol-level, this means signed arithmetic is inherently harder to secure.
Edge Cases
| Edge Case | Behavior | Security Implication |
|---|---|---|
SLT(0, 0) | Returns 0 (false) | Correct: 0 is not less than 0 |
SLT(-1, 0) | Returns 1 (true) | Correct: -1 < 0 in signed |
SLT(0, -1) | Returns 0 (false) | Correct: 0 > -1. Note: LT(0, 0xFF...FF) returns 1 (opposite!) |
SLT(-2^255, 2^255 - 1) | Returns 1 (true) | Min < Max: correct |
SLT(2^255 - 1, -2^255) | Returns 0 (false) | Max > Min: correct |
SLT(-2^255, -2^255) | Returns 0 (false) | Equal values: not less-than |
SLT(-1, -2) | Returns 0 (false) | -1 > -2: correct |
SLT(2^255, 0) | Returns 1 (true) | 2^255 unsigned = -2^255 signed: SLT says negative < 0 |
SLT(int256.max + 1, 0) | Returns 1 (true) | Signed overflow: 2^255 - 1 + 1 = -2^255 in signed |
Real-World Exploits
Exploit 1: Solidity SDIV Signed Overflow (Known Edge Case, Multiple Audits)
Root cause: The expression int256(-2^255) / int256(-1) should produce 2^255, but 2^255 exceeds int256.max (2^255 - 1), causing silent overflow.
Details: Two’s complement has an asymmetric range: [-2^255, 2^255 - 1]. The minimum value -2^255 has no positive counterpart that fits in int256. Dividing it by -1 (which should negate it) overflows. The EVM’s SDIV returns -2^255 unchanged in this case. If a contract uses SLT to check whether a division result is positive (SLT(0, result)) after this operation, it gets false (the result is negative), even though mathematically the quotient should be positive. This has been flagged in multiple security audits of DeFi protocols that handle signed arithmetic.
SLT’s role: SLT correctly reports that the overflowed value is negative, but the developer expected a positive result. The bug is in the arithmetic, not the comparison, but SLT propagates the incorrect sign into downstream logic.
References:
Exploit 2: Signed/Unsigned Confusion in Price Feed Processing
Root cause: Price delta from an oracle (signed, can be negative) compared with an unsigned threshold using the wrong comparison opcode.
Details: A pattern found in multiple audit reports: DeFi protocols receive signed price deltas from Chainlink or similar oracles. When processing these in Yul/assembly for gas efficiency, developers sometimes use lt (unsigned) instead of slt (signed) to check whether a price change is below a maximum allowed deviation. A negative price change (e.g., -5%) is represented as a large unsigned number, which passes an unsigned upper-bound check that should have rejected it as out-of-range.
// Intended: reject if absolute price change > 10%
// BUG: lt is unsigned; negative delta (0xFF...F6 = -10 in signed) > maxDelta unsigned
let withinBounds := lt(priceDelta, maxDelta) // Always false for negative deltas!
// Fix: need separate sign check with slt, then compare magnitudeSLT’s role: The correct implementation requires SLT to first determine the sign of the delta, then compare the magnitude. Using unsigned LT for what is fundamentally a signed comparison is the core vulnerability.
Exploit 3: BEC/SMT Overflow — Signed Interpretation of Overflow Results
Root cause: While BEC was fundamentally an unsigned overflow, the overflowed values cross the 2^255 boundary, meaning any subsequent signed comparison would produce inverted results.
Details: In the BEC batchOverflow exploit, the attacker passed _value = 2^255. After the overflow in cnt * _value, any contract that subsequently checked the inflated balances using signed comparison (SLT) would see 2^255 as -2^255 — the most negative possible value. This doesn’t directly affect BEC (which used unsigned comparisons), but it illustrates the danger: if a downstream contract receives tokens with balance 2^255 and uses signed arithmetic/comparisons, the balance appears as an enormous negative number, potentially breaking invariants.
References:
Attack Scenarios
Scenario A: SLT on Unsigned Token Balance
contract VulnerableVault {
function hasMinimumDeposit(uint256 amount) internal pure returns (bool) {
bool sufficient;
assembly {
// BUG: slt() interprets amount as signed
// If amount >= 2^255 (e.g., a high-supply token), it appears negative
sufficient := slt(999, amount) // "Is 999 < amount?"
// For amount = 2^255: slt(999, -2^255) = false (999 > -2^255)
// Deposit of 2^255 tokens rejected as "insufficient"
}
return sufficient;
}
}Scenario B: Signed Overflow in Accumulator
contract VulnerableAccumulator {
int256 public totalPnL;
function recordProfit(int256 amount) external {
require(amount > 0); // SGT: positive profits only
// BUG: no signed overflow check. If totalPnL is near int256.max,
// adding a positive amount overflows to a negative value
totalPnL += amount;
// totalPnL was 2^255 - 10, amount is 100
// New totalPnL = 2^255 + 90, which is -2^255 + 90 in signed
// SLT(totalPnL, 0) now returns true -- massive profit looks like a loss
if (totalPnL < 0) {
_triggerEmergencyShutdown(); // False alarm from signed overflow
}
}
}Scenario C: Cross-Contract Type Confusion
interface IOracle {
function getPrice() external view returns (int256); // Can be negative (funding rate)
}
contract VulnerableConsumer {
function processPrice(address oracle) external {
// Decoded as uint256 due to bug (wrong interface or raw call decoding)
uint256 price = uint256(IOracle(oracle).getPrice());
// If oracle returns int256(-50), price = 0xFF...CE (huge unsigned number)
require(price < MAX_PRICE); // LT: 0xFF...CE < MAX_PRICE? Probably false.
// But require(price > MIN_PRICE) passes because 0xFF...CE > anything small
// The negative price, now a huge unsigned value, is used in calculations
_executeTrade(price); // Trade at an astronomically inflated price
}
}Mitigations
| Threat | Mitigation | Implementation |
|---|---|---|
| T1: SLT on unsigned values | Never use SLT for uint256 comparisons | In Yul: lt(a, b) for unsigned, slt(a, b) for signed. Solidity handles this automatically |
| T2: Signed overflow at boundary | Check for signed overflow after arithmetic | require(!(a > 0 && b > 0 && result < 0)) for addition |
| T2: Negation overflow | Special-case int256.min | require(a != type(int256).min) before negation |
| T3: Cross-contract type mismatch | Enforce consistent typing at ABI boundaries | Use explicit interface types; validate ranges on decoded signed values |
| T4: Ordering inconsistency | Use consistent comparison across a codebase | Document whether each comparison point uses signed or unsigned semantics |
| T5: Assembly confusion | Code review for slt/lt confusion in Yul | Lint rules or static analysis to flag slt usage on known-unsigned variables |
Compiler/EIP-Based Protections
- Solidity type system: The compiler automatically emits SLT for
int256 < int256and LT foruint256 < uint256. Signed/unsigned confusion is primarily a risk in inline assembly. - Solidity 0.8.0+: Automatic revert on signed integer overflow (
int256.max + 1reverts). This prevents the most dangerous signed overflow patterns. - SafeCast (OpenZeppelin): Provides checked conversions between signed and unsigned types:
toInt256(uint256 value)reverts ifvalue > type(int256).max. - Static analysis: Slither and Mythril can detect signed/unsigned type confusion in some patterns.
Severity Summary
| Threat ID | Category | Severity | Likelihood | Real-World Precedent |
|---|---|---|---|---|
| T1 | Smart Contract | Critical | Medium | Assembly bugs in gas-optimized contracts |
| T2 | Smart Contract | Critical | Medium (pre-0.8) / Low (post-0.8) | SDIV edge case in multiple audits |
| T3 | Smart Contract | High | Medium | Oracle/price feed type mismatches |
| T4 | Smart Contract | High | Low | Sorting/indexing bugs with mixed types |
| T5 | Smart Contract | Medium | Medium | Assembly-heavy DEX code |
| P1 | Protocol | Low | N/A | — |
| P2 | Protocol | Low | N/A | — |
| P4 | Protocol | Medium | Low | Signed overflow detection complexity |
Related Opcodes
| Opcode | Relationship |
|---|---|
| LT (0x10) | Unsigned less-than; SLT and LT produce different results for any pair where one or both values have bit 255 set |
| SGT (0x13) | Signed greater-than; SGT(a, b) == SLT(b, a). Same signed threat class |
| GT (0x11) | Unsigned greater-than; the unsigned counterpart to SGT |
| SIGNEXTEND (0x0B) | Extends smaller signed types to 256 bits; incorrect sign extension before SLT comparison produces wrong results |
| SDIV (0x05) | Signed division; shares the int256.min / -1 overflow edge case that affects SLT-based sign checks |
| ISZERO (0x15) | Negates SLT results: a >= b (signed) compiles to ISZERO(SLT(a, b)) |
| SUB (0x03) | Subtraction can cause signed overflow that flips the sign, corrupting SLT comparisons |