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.
The operation surface
Section titled “The operation surface”| 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).
Checked semantics
Section titled “Checked semantics”| 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.
Wrapping arithmetic
Section titled “Wrapping arithmetic”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^256const 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 }); },);Exponentiation
Section titled “Exponentiation”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.
Modular arithmetic
Section titled “Modular arithmetic”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 });});Signedness selects the comparison
Section titled “Signedness selects the comparison”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) });});One type per operation
Section titled “One type per operation”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) }),);Conversions
Section titled “Conversions”toUint(target)/toInt(target)— numeric to numeric. Widening is free; narrowing is range-checked and reverts withPanic(0x11)when the value does not fit. Cross-signedness conversions are range-checked the same way:uint256toint256panics 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 ofaddvswrappingAdd).x.wrappingToUint(t.uint24)is Solidity’suint24(x),word.wrappingToInt(t.int24)isint24(uint24(word)), andi24.wrappingToUint(t.uint256)isuint256(int256(i24))(a negative value wraps to the top of the range). Each costs one mask (uintN) orSIGNEXTEND(intN), nothing when no value can change (a widening of the same sign,uintNto a widerintM, any 256-bit target).asAddress()— onExpr<'uint256'>orExpr<'bytes32'>; checked that the high 96 bits are zero (Panic(0x11)otherwise). OnExpr<'uint160'>it is free (Solidity’saddress(u160)).asUint160()— onExpr<'address'>, free (Solidity’suint160(a)). Like Solidity, an address converts to the one integer type of its width; widen from there withtoUint:a.asUint160().toUint(t.uint256). Solidity’saddress(b20)/bytes20(a)take two steps throughuint160(one shift each):b20.asUint().asAddress()anda.asUint160().asBytesN().asUint256()/asBytes32()— free reinterpretation betweenbytes32anduint256(both occupy the full word; no check, no cost).asUint()/asBytesN()— the same-widthbytesN↔uintNconversions of every width (bytes4↔uint32,bytes20↔uint160, …; Solidity’suint32(b)/bytes4(u)). Both are lossless, so they are unchecked; each costs one shift, since abytesNis left-aligned in its word and auintNright-aligned. As in Solidity there is no direct conversion between different widths: go throughtoUint(u64.toUint(t.uint256).asBytes32()) or the bit operations.asInt()/asUint()— the same-widthuintN↔intNreinterpretation (Solidity’sint24(u24)/uint24(i24)): the same N bits read as two’s complement, so0xffffffas anint24is-1and-1as auint24is0xffffff. Never checked;asInt()costs oneSIGNEXTEND,asUint()on anintNone mask.toInt/toUintstay the range-checked conversions of the same pairs.asBytes()/asString()—string↔bytes, free (the same memory, reinterpreted), andasString()on abytesN: 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 }); },);Example: packed signed fields
Section titled “Example: packed signed fields”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.
Example: a CREATE2 address
Section titled “Example: a CREATE2 address”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 }); },);Literal folding at recording time
Section titled “Literal folding at recording time”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 }); },);- Control flow — branching and looping on runtime values,
s.select. - Types reference — the full
Exprmethod tables. - Errors and debugging — the complete panic table and
explainRevert.