Skip to content

Arithmetic and comparisons

Arithmetic in evs is checked by default, matching solc 0.8 semantics exactly: overflow reverts with Panic(0x11), division by zero with Panic(0x12). Wrapping arithmetic (what Solidity computes inside an unchecked block) is an explicit opt-in, one operation at a time. Operations are methods on Expr, with the operand domain enforced by the type system.

Group Expr methods Operands Result
Checked arithmetic add sub mul div mod numeric (uintN / intN), same type same type
Wrapping arithmetic wrappingAdd wrappingSub wrappingMul numeric (uintN / intN), same type same type
Exponentiation pow numeric base; unsigned exponent (any uintN or literal) base type
Modular arithmetic addmod mulmod uint256 (value, operand, modulus) Expr<'uint256'>
Multiply-divide mulDiv mulDivRoundingUp uint256 (value, operand, denominator) Expr<'uint256'>
Ordering lt gt lte gte numeric, address or bytesN, same type Expr<'bool'>
Equality eq neq any word type, same type Expr<'bool'>
Boolean and or not bool Expr<'bool'>
Bitwise bitAnd bitOr bitXor bitNot uintN / bytesN, same type same type
Shifts shl shr value uintN / intN / bytesN; shift amount uint256 same as value
Conversions toUint toInt wrappingToUint wrappingToInt asAddress asUint160 asUint256 asBytes32 asUint asBytesN asInt asBytes asString see Conversions target type

Every method (except the conversions) also exists as a free function on the builder — s.add(a, b), s.lt(a, b), s.shl(a, bits), s.pow(base, e), s.mulmod(a, b, n), and so on — for literal-left cases like s.sub(100n, x). At least one operand of a free function must be an Expr; two plain literals throw EvsTypeError at recording (type one with s.lit). The exceptions are addmod / mulmod / mulDiv / mulDivRoundingUp, whose operands are always uint256 (all-literal calls simply fold), and pow and the shifts, whose left operand must be an Expr (its type is the result type).

Right-hand operands accept literals via IntoExpr coercion: x.add(1n), tick.lt(0n), flag.and(true).

eq/neq also work on memref types (string, bytes, T[], structs) as hash equality: a.eq(b) is recorded as s.keccak256(a).eq(s.keccak256(b)). For string/bytes that is byte-for-byte equality (the raw bytes are hashed, exactly like Solidity’s keccak256(bytes(a)) == keccak256(bytes(b)) idiom); for arrays and structs it is element-wise equality through their standard abi.encode (never the packed form, which is ambiguous for adjacent dynamic values). Both operands must have the same evs type — a word never compares with a memref, and string never compares with bytes or string[] — and the right-hand side takes the usual literals (symbol.eq('WETH'), fees.neq([500n, 3000n])). It costs two keccaks plus memory reads per comparison, so hoist a hash you compare repeatedly into one s.keccak256(...) value. A constant literal operand — a string/bytes literal, or an array of word literals like [500n, 3000n] — is hashed once at compile time instead: the script embeds its 32-byte hash and never builds the literal, so symbol.eq('WETH') runs a single keccak. Boolean and/or are eager, not short-circuiting: both sides are already-computed values by the time you combine them. For conditional execution, use s.if (control flow).

Condition Revert
add / sub / mul result out of range for the operand type Panic(0x11)
div / mod by zero Panic(0x12)
signed division of the type’s minimum value by −1 Panic(0x11)
pow result out of range for the base type Panic(0x11)
addmod / mulmod with a zero modulus Panic(0x12)
mulDiv / mulDivRoundingUp with a zero denominator Panic(0x12)
mulDiv / mulDivRoundingUp quotient out of range for uint256 Panic(0x11)
checked narrowing out of range (toUint / toInt / asAddress) Panic(0x11)

Checks run at the operand type’s width, not at 256 bits: a uint64 addition overflows at 2^64. The signed-division edge case is handled explicitly — the EVM’s SDIV silently wraps −2^255 / −1, but evs (like solc) reverts with Panic(0x11).

A literal operand makes a check cheaper, never different. An unsigned x.mul(1000n) is one comparison of x against ⌊max / 1000⌋ before the multiply instead of the general overflow test, an int256 x.add(k) / x.sub(k) checks only the overflow direction the literal’s sign allows, and a nonzero literal divisor drops the division-by-zero check.

Panics surface as standard Panic(uint256) reverts, which viem decodes natively; explainRevert explains the code and lists the candidate sites in your script that can raise that panic. See errors and debugging.

The wrapping… methods never revert: see wrapping arithmetic.

Shifts follow Solidity: they are unchecked, with the result re-canonicalized to the operand’s width — bits shifted out of a uint8 lane are simply dropped, never a panic. shr is a logical shift for uintN and bytesN and an arithmetic shift (SAR) for intN: it rounds toward negative infinity, exactly like Solidity’s >> on signed integers (-3 >> 1 == -2). shl on intN re-sign-extends from the width, so int8(127).shl(1) is -2. Note that shr on bytesN shifts within the full 256-bit word and re-masks to the left-aligned lane.

a.wrappingAdd(b), a.wrappingSub(b) and a.wrappingMul(b) (and the free functions s.wrappingAdd(a, b), …) are Solidity’s a + b, a - b and a * b inside an unchecked block: the result is the true result modulo 2^N of the operand type — two’s complement for intN — and they never revert. A uint256 / int256 operation is the bare ADD / SUB / MUL; a narrower width adds one mask (uintN) or SIGNEXTEND (intN) to keep its low N bits, so s.lit(t.uint8, 250).wrappingAdd(10n) is 4 and s.lit(t.int8, -128).wrappingSub(1n) is 127. Operand rules are those of the checked methods (one type, literals coerced).

Checked arithmetic stays the default and the wrapping methods are spelled out at every use, so an intentional wrap is visible where it happens and nothing else changes meaning. Reach for them when wrapping is the semantics — fee-growth accumulators that are designed to overflow, hash mixing, two’s-complement negation (s.wrappingSub(0n, x)) — or in a hot loop where an overflow is impossible by construction. A wrapping mul costs about 23 gas against 75 for the checked one. There is no wrapping div / mod / pow: division only ever overflows on minN / −1, and a zero divisor still panics in Solidity’s unchecked blocks.

import { evscript, t } from '@maxencerb/evs';
const Q128 = 1n << 128n;
// Uniswap v3 fees owed to a position (Tick.getFeeGrowthInside + Position.update): the
// fee-growth accumulators wrap by design, so every difference is taken modulo 2^256
const owed = evscript(
{
name: 'feesOwed',
args: [t.int24, t.int24, t.int24, t.uint256, t.uint256, t.uint256, t.uint256, t.uint128],
},
(s, tick, tickLower, tickUpper, global, lowerOutside, upperOutside, insideLast, liquidity) => {
const below = s.select(tick.gte(tickLower), lowerOutside, global.wrappingSub(lowerOutside));
const above = s.select(tick.lt(tickUpper), upperOutside, global.wrappingSub(upperOutside));
const inside = global.wrappingSub(below).wrappingSub(above);
// FullMath.mulDiv(inside - insideLast, liquidity, Q128)
const fees = inside.wrappingSub(insideLast).mulDiv(liquidity.toUint(t.uint256), Q128);
return s.return({ inside, fees });
},
);

a.pow(e) is Solidity’s a ** e under checked arithmetic: the exact power, Panic(0x11) when it does not fit the base’s type, and 0 ** 0 == 1. The result has the base’s type; the exponent is unsigned — a non-negative literal or an Expr of any uintN width (a signed exponent is a type error, as in Solidity). Signed bases follow the asymmetric range exactly: (-2) ** 7 is -128 and fits int8, 2 ** 7 overflows it, and (-1) ** e never overflows whatever e is.

import { evscript, t } from '@maxencerb/evs';
const scale = evscript({ name: 'scale', args: [t.uint256, t.uint8] }, (s, amount, decimals) => {
// 10 ** decimals — a literal base compiles to one bound check (decimals ≤ 77) and one EXP
const unit = s.lit(t.uint256, 10n).pow(decimals);
// a literal exponent compiles to one check on the base (amount ≤ ⌊(2^256 − 1)^(1/3)⌋) and one EXP
const cubed = amount.pow(3n);
return s.return({ whole: amount.div(unit), cubed });
});

What it costs depends on what is known at recording time. A literal base (s.lit(t.uint256, 2n).pow(e)) is one comparison against the largest exponent that fits plus one EXP — for 2 ** e on uint256 exactly solc’s e > 255 check. A literal exponent (x.pow(3n)) is one range check on the base against the precomputed integer root of the type’s bound, then one EXP. When both are runtime values evs runs solc’s square-and-multiply loop, with an overflow check before every squaring: at most 7 iterations (an exponent above 255 with a base of magnitude ≥ 2 panics straight away), about 1,100 gas in the worst case against roughly 100 for the literal-base and literal-exponent forms, and a single EXP when the base is 0/±1 or the exponent is 0.

a.addmod(b, n) and a.mulmod(b, n) are Solidity’s addmod(a, b, n) / mulmod(a, b, n): (a + b) % n and (a * b) % n computed at full precision — the intermediate sum or product is never truncated to 256 bits, so mulmod(x, y, p) is the right tool for modular arithmetic over a large prime where x.mul(y).mod(p) would overflow. They are uint256-only (value, operand and modulus), and a zero modulus reverts with Panic(0x12), like div / mod. A nonzero literal modulus drops that runtime check, as a literal divisor does for div / mod.

import { evscript, t } from '@maxencerb/evs';
const P = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
const field = evscript({ name: 'field', args: [t.uint256, t.uint256] }, (s, x, y) => {
const prod = x.mulmod(y, P); // (x * y) mod P, no 512-bit overflow
const sum = s.addmod(x, y, P);
return s.return({ prod, sum });
});

mulDiv: full-precision multiply-then-divide

Section titled “mulDiv: full-precision multiply-then-divide”

a.mulDiv(b, d) is FullMath’s mulDiv(a, b, d): ⌊a · b / d⌋ with the product kept at 512 bits, so it is exact whenever the quotient itself fits uint256, where a.mul(b).div(d) reverts as soon as a · b passes 2^256. a.mulDivRoundingUp(b, d) is the ceiling ⌈a · b / d⌉. Both are uint256-only (value, operand and denominator) and use OpenZeppelin Math.mulDiv’s Panic codes: a zero denominator reverts with Panic(0x12), a quotient that does not fit uint256 with Panic(0x11) (for mulDivRoundingUp, that includes rounding 2^256 − 1 up). Uniswap’s FullMath reverts with empty data in those two cases instead; the results are the same everywhere else.

The lowering is the FullMath sequence, verified against a solc-compiled reference: when a · b fits one word it is a single DIV (about 150 gas), otherwise the 512-bit path runs (about 480 gas). A nonzero literal denominator drops the zero check, as for div. A script with a single mulDiv site inlines the sequence (about 110 bytes); with several, it is emitted once as a shared subroutine that each site calls, for about 8 bytes and 36 gas per call (about 10 bytes and 61 gas when the script mixes mulDiv and mulDivRoundingUp), so extra sites cost no more code than a.mul(b).div(d) does.

import { evscript, t } from '@maxencerb/evs';
const Q96 = 1n << 96n;
const spotPrice = evscript({ name: 'spotPrice', args: [t.uint160] }, (s, sqrtPriceX96) => {
const p = sqrtPriceX96.toUint(t.uint256);
// token1 per token0, 18 decimals: sqrtP² / 2^192 · 1e18 (sqrtP² alone can reach 2^320)
const price = p.mulDiv(p, Q96).mulDiv(10n ** 18n, Q96);
const priceCeil = p.mulDivRoundingUp(p, Q96).mulDivRoundingUp(10n ** 18n, Q96);
return s.return({ price, priceCeil });
});

lt/gt/lte/gte compile to the EVM’s unsigned LT/GT for uintN and to signed SLT/SGT for intN, chosen from the static type of the operands (lte/gte are ISZERO of the opposite strict comparison). You never pick an opcode; declaring the right type is enough:

import { evscript, t } from '@maxencerb/evs';
const tickSide = evscript({ name: 'tickSide', args: [t.int24] }, (s, tick) => {
const isNegative = tick.lt(0n); // int24 → SLT: −100 < 0 is true
// literal-left subtraction via the free function; checked, so negating the
// int24 minimum value would revert with Panic(0x11)
const magnitude = s.select(isNegative, s.sub(0n, tick), tick);
return s.return({ isNegative, magnitude });
});

The same selection logic keeps signed values canonical everywhere: intN words are sign-extended, and every operation preserves that invariant.

address and bytesN order too, as in Solidity: both compare as unsigned words (LT/GT), so addresses order by their numeric value and a bytesN orders byte by byte from the left (0xff000000 sorts after 0x00ffffff). That is what sorting a token pair takes, as Uniswap’s token0 < token1 rule does:

import { evscript, t } from '@maxencerb/evs';
const sortTokens = evscript({ name: 'sortTokens', args: [t.address, t.address] }, (s, a, b) => {
const aFirst = a.lt(b); // address < address: an unsigned word comparison
return s.return({ token0: s.select(aFirst, a, b), token1: s.select(aFirst, b, a) });
});

Both operands must have the same evs type. The TS surface enforces it (the right-hand side of a.add(b) must be IntoExpr of a’s type), and the recorder double-checks at recording time — mixing two Exprs of different types throws EvsTypeError naming both types. There are no implicit widenings.

This does not compile:

import { evscript, t } from '@maxencerb/evs';
const mix = evscript(
{ name: 'mix', args: [t.uint128, t.uint256] },
// TS error: Expr<'uint256'> is not assignable to IntoExpr<'uint128'>
(s, a, b) => s.return({ sum: a.add(b) }),
);

Convert explicitly instead:

import { evscript, t } from '@maxencerb/evs';
const mix = evscript(
{ name: 'mix', args: [t.uint128, t.uint256] },
(s, a, b) => s.return({ sum: a.toUint(t.uint256).add(b) }),
);
  • toUint(target) / toInt(target) — numeric to numeric. Widening is free; narrowing is range-checked and reverts with Panic(0x11) when the value does not fit. Cross-signedness conversions are range-checked the same way: uint256 to int256 panics for values of 2^255 and above.
  • wrappingToUint(target) / wrappingToInt(target) — the same pairs with Solidity’s explicit-conversion semantics: keep the low N bits of the value’s two’s complement, never revert (the split of add vs wrappingAdd). x.wrappingToUint(t.uint24) is Solidity’s uint24(x), word.wrappingToInt(t.int24) is int24(uint24(word)), and i24.wrappingToUint(t.uint256) is uint256(int256(i24)) (a negative value wraps to the top of the range). Each costs one mask (uintN) or SIGNEXTEND (intN), nothing when no value can change (a widening of the same sign, uintN to a wider intM, any 256-bit target).
  • asAddress() — on Expr<'uint256'> or Expr<'bytes32'>; checked that the high 96 bits are zero (Panic(0x11) otherwise). On Expr<'uint160'> it is free (Solidity’s address(u160)).
  • asUint160() — on Expr<'address'>, free (Solidity’s uint160(a)). Like Solidity, an address converts to the one integer type of its width; widen from there with toUint: a.asUint160().toUint(t.uint256). Solidity’s address(b20) / bytes20(a) take two steps through uint160 (one shift each): b20.asUint().asAddress() and a.asUint160().asBytesN().
  • asUint256() / asBytes32() — free reinterpretation between bytes32 and uint256 (both occupy the full word; no check, no cost).
  • asUint() / asBytesN() — the same-width bytesN ↔ uintN conversions of every width (bytes4 ↔ uint32, bytes20 ↔ uint160, …; Solidity’s uint32(b) / bytes4(u)). Both are lossless, so they are unchecked; each costs one shift, since a bytesN is left-aligned in its word and a uintN right-aligned. As in Solidity there is no direct conversion between different widths: go through toUint (u64.toUint(t.uint256).asBytes32()) or the bit operations.
  • asInt() / asUint() — the same-width uintN ↔ intN reinterpretation (Solidity’s int24(u24) / uint24(i24)): the same N bits read as two’s complement, so 0xffffff as an int24 is -1 and -1 as a uint24 is 0xffffff. Never checked; asInt() costs one SIGNEXTEND, asUint() on an intN one mask. toInt / toUint stay the range-checked conversions of the same pairs.
  • asBytes() / asString() — string ↔ bytes, free (the same memory, reinterpreted), and asString() on a bytesN: see bytes and strings.

A bytesN has no byteAt / slice of its own (Solidity’s b32[i]): shift and mask after asUint(), or index its N bytes as a bytes through s.encodePacked(word).

A word-to-word conversion of a literal folds at recording, like the arithmetic below (a wrapping one to its wrapped value).

import { evscript, t } from '@maxencerb/evs';
const casts = evscript(
{ name: 'casts', args: [t.uint256, t.bytes32] },
(s, x, w) => {
const small = x.toUint(t.uint64); // checked narrowing: Panic(0x11) if x ≥ 2^64
const wide = small.toUint(t.uint256); // widening: free
const signed = x.toInt(t.int256); // checked: Panic(0x11) if x ≥ 2^255
const addr = w.asAddress(); // checked: high 96 bits must be zero
const word = w.asUint256(); // free reinterpret
const id = addr.asUint160().toUint(t.uint256); // uint256(uint160(addr))
const selector = w.asUint().toUint(t.uint32).asBytesN(); // Expr<'bytes4'>: Panic(0x11) unless w < 2^32
return s.return({ small, wide, signed, addr, word, id, selector });
},
);

A wrapping conversion reads the signed field of a packed word in one opcode. Uniswap v4 packs a pool’s Slot0 as sqrtPriceX96:160 | tick:int24 | protocolFee:24 | lpFee:24, read with extsload (its StateLibrary.getSlot0 decodes the tick with signextend(2, shr(160, data))):

import { evscript, t } from '@maxencerb/evs';
const POOL_MANAGER = '0x000000000004444c5dc75cB358380D2e3dE08A90';
const extsloadAbi = [
{
type: 'function',
name: 'extsload',
stateMutability: 'view',
inputs: [{ name: 'slot', type: 'bytes32' }],
outputs: [{ name: 'value', type: 'bytes32' }],
},
] as const;
const slot0 = evscript({ name: 'slot0', args: [t.bytes32] }, (s, stateSlot) => {
const raw = s.read({
address: POOL_MANAGER,
abi: extsloadAbi,
functionName: 'extsload',
args: [stateSlot],
});
const data = raw.asUint256();
return s.return({
sqrtPriceX96: data.wrappingToUint(t.uint160), // uint160(data)
tick: data.shr(160n).wrappingToInt(t.int24), // int24(uint24(data >> 160)): one SIGNEXTEND
protocolFee: data.shr(184n).wrappingToUint(t.uint24),
lpFee: data.shr(208n).wrappingToUint(t.uint24),
});
});

The checked data.shr(160n).toUint(t.uint24) would revert on the bits above the tick, and a checked toInt(t.int24) of the uint24 on every negative tick: a packed field wants the bit-level conversion.

Address ordering, hashing and asAddress() are what deriving a CREATE2 address takes, here a Uniswap V3 pool:

import { evscript, t } from '@maxencerb/evs';
const FACTORY = '0x1F98431c8aD98523631AE4a59f267346ea31F984';
const POOL_INIT_CODE_HASH = '0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54';
const LOW_160 = (1n << 160n) - 1n;
const poolAddress = evscript(
{ name: 'poolAddress', args: [t.address, t.address, t.uint24] },
(s, tokenA, tokenB, fee) => {
const aFirst = tokenA.lt(tokenB); // Uniswap's token0 < token1 ordering
const token0 = s.select(aFirst, tokenA, tokenB);
const token1 = s.select(aFirst, tokenB, tokenA);
const salt = s.keccak256(token0, token1, fee); // keccak256(abi.encode(token0, token1, fee))
const hash = s.keccak256(
s.encodePacked(
s.lit(t.bytes1, '0xff'),
s.lit(t.address, FACTORY),
salt,
s.lit(t.bytes32, POOL_INIT_CODE_HASH),
),
);
// address(uint160(uint256(hash))): keep the low 20 bytes, so asAddress()'s check never fires
const pool = hash.asUint256().bitAnd(LOW_160).asAddress();
return s.return({ token0, token1, pool });
},
);

Pure operations whose operands are all statically known fold at recording — no bytecode is emitted for them:

import { evscript, t } from '@maxencerb/evs';
const fees = evscript({ name: 'fees', args: [t.uint256] }, (s, amount) => {
// all operands statically known → folds at recording into the constant 60
const feeBps = s.lit(t.uint256, 30n).mul(2n);
const fee = amount.mul(feeBps).div(10_000n);
return s.return({ fee });
});

A fold of a wrapping operation wraps (s.lit(t.uint8, 250).wrappingAdd(10n) folds to 4). If a fold would certainly panic — s.lit(t.uint8, 250).add(10n), a literal division by zero, an out-of-range toUint of a known value — recording throws EvsTypeError (code CERTAIN_PANIC) at that line rather than compiling a guaranteed revert. If a guaranteed runtime panic is what you actually want, route one operand through a cell so its value is no longer statically known: s.let(t.uint256, x).get(), and use the result (return it, or feed it to something that is kept). A result nothing reads is dead code: compile() drops it, its panic included (see Unused results do not revert).

Worked example: share of supply in basis points

Section titled “Worked example: share of supply in basis points”
import { evscript, t } from '@maxencerb/evs';
import { erc20Abi } from 'viem';
const shareOfSupply = evscript(
{ name: 'shareOfSupply', args: [t.address, t.address] },
(s, token, owner) => {
const bal = s.read({
address: token,
abi: erc20Abi,
functionName: 'balanceOf',
args: [owner],
});
const supply = s.read({ address: token, abi: erc20Abi, functionName: 'totalSupply' });
// bal * 10_000 / supply — mul reverts with Panic(0x11) on overflow,
// div with Panic(0x12) if totalSupply() returns zero
const bps = bal.mul(10_000n).div(supply);
const isWhale = bps.gte(100n); // ≥ 1% of supply (uint256 → unsigned comparison)
return s.return({ bps, isWhale });
},
);