Skip to main content

Overview

Opcode: 0x12 Introduced: Frontier (EVM genesis) SLT performs signed less than comparison on two 256-bit integers interpreted as two’s complement signed values. Returns 1 if the first value is strictly less than the second, 0 otherwise. Values are in the range -2^255 to 2^255 - 1. This operation is critical for signed integer arithmetic and conditions involving negative values.

Specification

Stack Input:
a (top)
b
Stack Output:
signed(a) < signed(b) ? 1 : 0
Gas Cost: 3 (GasFastestStep) Operation:
// Interpret as signed two's complement
signed_a = a >= 2^255 ? a - 2^256 : a
signed_b = b >= 2^255 ? b - 2^256 : b
result = (signed_a < signed_b) ? 1 : 0

Behavior

SLT pops two values from the stack, interprets them as signed 256-bit two’s complement integers, compares them, and pushes 1 if signed(a) < signed(b), otherwise 0:
  • If signed(a) < signed(b): Result is 1 (true)
  • If signed(a) >= signed(b): Result is 0 (false)
Two’s complement interpretation:
  • Bit 255 = 0: Positive (0 to 2^255 - 1)
  • Bit 255 = 1: Negative (-2^255 to -1)

Examples

Positive Values

import { slt } from '@tevm/voltaire/evm/comparison';
import { createFrame } from '@tevm/voltaire/evm/Frame';

// 10 < 20 = 1 (both positive)
const frame = createFrame({ stack: [10n, 20n] });
const err = slt(frame);

console.log(frame.stack); // [1n]
console.log(frame.gasRemaining); // Original - 3

Negative Less Than Positive

// -1 < 10 = 1 (true)
const NEG_1 = (1n << 256n) - 1n;  // Two's complement -1
const frame = createFrame({ stack: [NEG_1, 10n] });
slt(frame);

console.log(frame.stack); // [1n]

Positive Greater Than Negative

// 10 < -1 = 0 (false, 10 > -1)
const NEG_1 = (1n << 256n) - 1n;
const frame = createFrame({ stack: [10n, NEG_1] });
slt(frame);

console.log(frame.stack); // [0n]

Negative Value Comparison

// -10 < -5 = 1 (true)
const NEG_10 = (1n << 256n) - 10n;
const NEG_5 = (1n << 256n) - 5n;
const frame = createFrame({ stack: [NEG_10, NEG_5] });
slt(frame);

console.log(frame.stack); // [1n]

Zero Boundary

// -1 < 0 = 1 (true)
const NEG_1 = (1n << 256n) - 1n;
const frame = createFrame({ stack: [NEG_1, 0n] });
slt(frame);

console.log(frame.stack); // [1n]

// 0 < 1 = 1 (true)
const frame2 = createFrame({ stack: [0n, 1n] });
slt(frame2);
console.log(frame2.stack); // [1n]

Minimum and Maximum

// MIN_INT256 < MAX_INT256 = 1
const MIN_INT256 = 1n << 255n;  // -2^255
const MAX_INT256 = (1n << 255n) - 1n;  // 2^255 - 1
const frame = createFrame({ stack: [MIN_INT256, MAX_INT256] });
slt(frame);

console.log(frame.stack); // [1n]

Contrast with Unsigned LT

// 2^255 has bit 255 set
const SIGN_BIT = 1n << 255n;

// SLT: -2^255 < 1 = 1 (true, signed)
const frame1 = createFrame({ stack: [SIGN_BIT, 1n] });
slt(frame1);
console.log(frame1.stack); // [1n]

// LT: 2^255 < 1 = 0 (false, unsigned - 2^255 is huge positive)
const frame2 = createFrame({ stack: [SIGN_BIT, 1n] });
lt(frame2);
console.log(frame2.stack); // [0n]

Gas Cost

Cost: 3 gas (GasFastestStep) SLT shares the lowest gas tier with all comparison operations:
  • LT, GT, SLT, SGT, EQ (comparisons)
  • ISZERO, NOT
  • ADD, SUB
Comparison:
  • SLT/SGT/LT/GT: 3 gas
  • MUL/DIV: 5 gas
  • SDIV/SMOD: 5 gas

Edge Cases

Signed Boundary Values

const MIN_INT256 = 1n << 255n;  // -2^255
const MAX_INT256 = (1n << 255n) - 1n;  // 2^255 - 1
const NEG_1 = (1n << 256n) - 1n;  // -1

// MIN < MAX
slt(createFrame({ stack: [MIN_INT256, MAX_INT256] }));  // [1n]

// MAX > MIN
slt(createFrame({ stack: [MAX_INT256, MIN_INT256] }));  // [0n]

// -1 < 0
slt(createFrame({ stack: [NEG_1, 0n] }));  // [1n]

// 0 > -1
slt(createFrame({ stack: [0n, NEG_1] }));  // [0n]

Equal Values

// Any value compared to itself
const NEG_10 = (1n << 256n) - 10n;

slt(createFrame({ stack: [20n, 20n] }));  // [0n]
slt(createFrame({ stack: [NEG_10, NEG_10] }));  // [0n]
slt(createFrame({ stack: [0n, 0n] }));  // [0n]

Sign Bit Boundary

// Just below sign bit (largest positive)
const MAX_POS = (1n << 255n) - 1n;

// Just at sign bit (smallest negative)
const MIN_NEG = 1n << 255n;

// Unsigned: MIN_NEG > MAX_POS
lt(createFrame({ stack: [MIN_NEG, MAX_POS] }));  // [0n]

// Signed: MIN_NEG < MAX_POS
slt(createFrame({ stack: [MIN_NEG, MAX_POS] }));  // [1n]

Stack Underflow

// Not enough stack items
const frame = createFrame({ stack: [10n] });
const err = slt(frame);

console.log(err); // { type: "StackUnderflow" }
console.log(frame.stack); // [10n] (unchanged)

Out of Gas

// Insufficient gas
const frame = createFrame({ stack: [10n, 20n], gasRemaining: 2n });
const err = slt(frame);

console.log(err); // { type: "OutOfGas" }
console.log(frame.gasRemaining); // 0n

Common Usage

Signed Bounds Checking

// require(signedValue < max)
assembly {
    if iszero(slt(signedValue, max)) {
        revert(0, 0)
    }
}

Negative Value Check

// Check if value is negative
assembly {
    let isNegative := slt(value, 0)
    if isNegative {
        revert(0, 0)
    }
}

Signed Range Validation

// Check if value in signed range [min, max]
assembly {
    let inRange := and(
        iszero(slt(value, min)),  // value >= min
        iszero(sgt(value, max))   // value <= max
    )
}

Absolute Value

// abs(value)
assembly {
    let abs := value
    if slt(value, 0) {
        abs := sub(0, value)  // Negate
    }
}

Sign Function

// sign(value): -1, 0, or 1
assembly {
    let s := 0
    if slt(value, 0) {
        s := sub(0, 1)  // -1
    }
    if sgt(value, 0) {
        s := 1
    }
}

Implementation

/**
 * SLT opcode (0x12) - Signed less than comparison
 */
export function handle(frame: FrameType): EvmError | null {
  // Consume gas (GasFastestStep = 3)
  const gasErr = consumeGas(frame, FastestStep);
  if (gasErr) return gasErr;

  // Pop operands (b is top, a is second)
  const bResult = popStack(frame);
  if (bResult.error) return bResult.error;
  const b = bResult.value;

  const aResult = popStack(frame);
  if (aResult.error) return aResult.error;
  const a = aResult.value;

  // Convert to signed and compare
  const aSigned = toSigned256(a);
  const bSigned = toSigned256(b);
  const result = aSigned < bSigned ? 1n : 0n;

  // Push result
  const pushErr = pushStack(frame, result);
  if (pushErr) return pushErr;

  // Increment PC
  frame.pc += 1;
  return null;
}

/**
 * Convert unsigned 256-bit to signed two's complement
 */
function toSigned256(value: bigint): bigint {
  const MAX_INT256 = 1n << 255n;
  if (value >= MAX_INT256) {
    return value - (1n << 256n);
  }
  return value;
}

Testing

Test Coverage

import { describe, it, expect } from 'vitest';
import { handle as SLT } from './0x12_SLT.js';

describe('SLT (0x12)', () => {
  it('returns 1 when a < b (both positive)', () => {
    const frame = createFrame([10n, 20n]);
    expect(SLT(frame)).toBeNull();
    expect(frame.stack).toEqual([1n]);
    expect(frame.gasRemaining).toBe(997n);
  });

  it('returns 1 when negative < positive', () => {
    const NEG_1 = (1n << 256n) - 1n;
    const frame = createFrame([NEG_1, 10n]);
    expect(SLT(frame)).toBeNull();
    expect(frame.stack).toEqual([1n]); // -1 < 10
  });

  it('returns 0 when positive > negative', () => {
    const NEG_1 = (1n << 256n) - 1n;
    const frame = createFrame([10n, NEG_1]);
    expect(SLT(frame)).toBeNull();
    expect(frame.stack).toEqual([0n]); // 10 > -1
  });

  it('compares negative numbers correctly', () => {
    const NEG_10 = (1n << 256n) - 10n;
    const NEG_5 = (1n << 256n) - 5n;
    const frame = createFrame([NEG_10, NEG_5]);
    expect(SLT(frame)).toBeNull();
    expect(frame.stack).toEqual([1n]); // -10 < -5
  });

  it('handles -1 < 0', () => {
    const NEG_1 = (1n << 256n) - 1n;
    const frame = createFrame([NEG_1, 0n]);
    expect(SLT(frame)).toBeNull();
    expect(frame.stack).toEqual([1n]);
  });

  it('handles MIN_INT256 < MAX_INT256', () => {
    const MIN = 1n << 255n;
    const MAX = (1n << 255n) - 1n;
    const frame = createFrame([MIN, MAX]);
    expect(SLT(frame)).toBeNull();
    expect(frame.stack).toEqual([1n]);
  });

  it('returns 0 when a >= b (equal)', () => {
    const frame = createFrame([20n, 20n]);
    expect(SLT(frame)).toBeNull();
    expect(frame.stack).toEqual([0n]);
  });

  it('returns StackUnderflow with insufficient stack', () => {
    const frame = createFrame([10n]);
    expect(SLT(frame)).toEqual({ type: 'StackUnderflow' });
  });

  it('preserves stack below compared values', () => {
    const frame = createFrame([100n, 200n, 10n, 20n]);
    expect(SLT(frame)).toBeNull();
    expect(frame.stack).toEqual([100n, 200n, 1n]);
  });
});

Edge Cases Tested

  • Positive value comparisons
  • Negative less than positive
  • Positive greater than negative
  • Negative value comparisons (-10 < -5)
  • Zero boundary (-1 < 0, 0 < 1)
  • MIN_INT256 and MAX_INT256
  • Equal values
  • Stack underflow
  • Out of gas
  • Stack preservation

Security

Critical: Signed vs Unsigned Confusion

MOST COMMON VULNERABILITY: Using LT instead of SLT for signed values:
// VULNERABLE: Using LT for signed comparison
function withdraw(int256 amount) {
    // LT treats -1 as 2^256-1 (huge positive!)
    assembly {
        if lt(balance, amount) {  // WRONG!
            revert(0, 0)
        }
    }
    // Attacker can pass negative amount to bypass check
}

// CORRECT: Use SLT for signed values
function withdraw(int256 amount) {
    assembly {
        if slt(balance, amount) {  // Correct
            revert(0, 0)
        }
    }
}

Integer Type Casting

// VULNERABLE: Unsafe cast before comparison
function compareValues(uint256 a, int256 b) {
    // Casting signed to unsigned loses sign information
    uint256 b_unsigned = uint256(b);  // -1 becomes 2^256-1
    require(a < b_unsigned);  // Wrong comparison!
}

// CORRECT: Keep signed types consistent
function compareValues(int256 a, int256 b) {
    require(a < b);  // Compiler uses SLT
}

Overflow in Signed Arithmetic

// VULNERABLE: Overflow before comparison
int256 sum = a + b;  // May overflow
require(sum > a);    // Check may be wrong

// CORRECT: Check before operation
require(a > 0 && b > type(int256).max - a, "overflow");
int256 sum = a + b;

Sign Extension Issues

// VULNERABLE: Incorrect sign extension
int8 small = -1;
int256 large = int256(uint256(uint8(small)));  // Wrong! Becomes 255

// CORRECT: Proper sign extension
int256 large = int256(small);  // Correctly -1

Optimizations

Two’s Complement Implementation

The implementation efficiently converts to signed for comparison:
// Efficient: Single branch
function toSigned256(value: bigint): bigint {
  const MAX_INT256 = 1n << 255n;
  if (value >= MAX_INT256) {
    return value - (1n << 256n);  // Subtract modulus
  }
  return value;
}

// Equivalent but more complex:
function toSigned256Alt(value: bigint): bigint {
  if (value & (1n << 255n)) {  // Check sign bit
    return -(((~value) & ((1n << 256n) - 1n)) + 1n);  // Two's complement
  }
  return value;
}

Comparison Patterns

// Check if negative (most common pattern)
assembly {
    let isNeg := slt(value, 0)  // 3 gas
}

// Equivalent but more expensive:
assembly {
    let signBit := shr(255, value)  // 3 gas
    let isNeg := eq(signBit, 1)     // 3 gas (total: 6 gas)
}

Benchmarks

SLT performance matches other comparison operations: Execution time (relative):
  • SLT: 1.05x (slightly slower due to sign conversion)
  • LT/GT/EQ: 1.0x
  • SGT: 1.05x
  • ISZERO: 0.95x
Gas efficiency:
  • 3 gas per signed comparison
  • ~333,333 comparisons per million gas
  • Sign conversion adds negligible overhead

References

  • SGT - Signed greater than
  • LT - Unsigned less than
  • GT - Unsigned greater than
  • SDIV - Signed division
  • SMOD - Signed modulo