Skip to content

Hashing and ABI encoding

evs mirrors Solidity’s three encoding-and-hashing primitives on the builder, byte-exact with what solc produces:

Op Solidity equivalent Result
s.encode(...values) abi.encode(...) Expr<'bytes'>
s.encodePacked(...values) abi.encodePacked(...) Expr<'bytes'>
s.keccak256(...values) keccak256(abi.encode(...)) Expr<'bytes32'>

All three take staged valuesExprs, Tuple handles, MutArray handles — and at least one of them. A raw literal has no unambiguous ABI type, so lift it with s.lit first:

import { evscript, t } from '@maxencerb/evs';
const transferTopic = evscript({ name: 'transferTopic', args: [] }, (s) => {
const sig = s.lit(t.string, 'Transfer(address,address,uint256)');
return s.return({ topic: s.keccak256(sig) });
});

s.keccak256 — the standard-encoding default

Section titled “s.keccak256 — the standard-encoding default”

s.keccak256(...values) hashes the standard ABI encoding of its values — keccak256(abi.encode(...)). It accepts everything s.encode accepts, so structs, nested arrays, and string[] hash directly; a struct value gives the EIP-712-style leaf hash:

import { evscript, t } from '@maxencerb/evs';
const Pair = t.struct({ token: t.address, fee: t.uint24 });
const structHash = evscript({ name: 'structHash', args: [Pair] }, (s, pair) => {
// keccak256(abi.encode(pair)) — the EIP-712-style leaf hash, no composition needed
return s.return({ h: s.keccak256(pair) });
});

One exception: a single bytes or string value is hashed directly — its raw payload, no encoding, no copy — which is exactly Solidity’s keccak256(someBytes). That rule is what makes the explicit compositions below hash precisely the bytes they name (s.keccak256(s.encode(...)) never double-encodes).

A single word hashes its full 32-byte standard encoding regardless of width: a uint8 and a uint256 both hash 32 padded bytes, matching keccak256(abi.encode(x)) in Solidity.

abi.encodePacked is non-standard, and hashing it implicitly is a footgun (see the ambiguity caveat below) — so evs never packs unless you say so. The packed hash is the composition:

import { evscript, t } from '@maxencerb/evs';
const pairKey = evscript(
{ name: 'pairKey', args: [t.address, t.address, t.uint24] },
(s, tokenA, tokenB, fee) => {
// keccak256(abi.encodePacked(tokenA, tokenB, fee)) — 20 + 20 + 3 bytes
const key = s.keccak256(s.encodePacked(tokenA, tokenB, fee));
// bytes32 → uint256 view when you need a number
return s.return({ key, keyAsUint: key.asUint256() });
},
);

This works because s.encodePacked(...) produces a bytes value and a single bytes value hashes raw — the hash covers exactly the packed encoding.

s.encode produces the head/tail abi.encode bytes of its values and accepts the whole v0 type vocabulary: words, string/bytes, arrays (including string[], uint256[][], and arrays of structs), and t.struct/t.tuple values — bare Tuple and MutArray handles are fine, just like in s.return:

import { evscript, t } from '@maxencerb/evs';
const Pair = t.struct({ token: t.address, fee: t.uint24 });
const encodeOrder = evscript(
{ name: 'encodeOrder', args: [t.uint256, t.array(t.uint128)] },
(s, id, amounts) => {
const pair = s.tuple(Pair, { token: '0x00000000000000000000000000000000deadbeef', fee: 500n });
return s.return({ blob: s.encode(id, pair, amounts) });
},
);

The output matches abi.encode (and viem’s encodeAbiParameters) byte for byte: 32-byte head words, dynamic members as offsets relative to the start of the encoding, tails in declaration order.

Packed encoding follows Solidity’s rules exactly:

  • sub-32-byte word types pack unpadded: uintN/intN take N / 8 bytes, address 20, bool 1, bytesN exactly N;
  • string and bytes contribute their raw payload with no length prefix;
  • a word-element array (uint16[], address[], …) packs each element padded to 32 bytes — Solidity pads array elements even in packed mode.

The shapes Solidity rejects in abi.encodePacked — structs, nested arrays, and string[]/bytes[] — are rejected at recording time with an EvsTypeError, so every packed encoding evs produces is one solc could have produced. Use s.encode for those types.

import { evscript, t } from '@maxencerb/evs';
const packed = evscript({ name: 'packed', args: [t.uint8, t.string, t.address] }, (s, v, name, who) => {
// 1 byte ++ raw utf8 ++ 20 bytes — no padding, no length words
return s.return({ blob: s.encodePacked(v, name, who) });
});

Note the usual packed caveat (inherited from Solidity, not fixed by evs): packed encoding is ambiguous for adjacent dynamic values — encodePacked("ab", "c") equals encodePacked("a", "bc"). This is why packed hashing is opt-in: don’t hash attacker-controlled adjacent dynamic values with s.keccak256(s.encodePacked(...)); the s.keccak256(...) default (standard encoding) is unambiguous.

Both encoders materialize a fresh bytes value in script memory (evs never frees memory, so an encode inside a loop allocates every iteration — compile() warns with the LOOP_ALLOCATION diagnostic). s.keccak256 over a single bytes/string performs no allocation at all — it hashes the existing value in place. None of these operations can revert.