Moved from rainlanguage/rain.lib.hash#7 (a bare link to EIP-712). The design survey behind this is archived at /home/gildlab/artifacts/rain.lib.hash-2026-09-03/DESIGN-7.md on the ops box; the parts that matter are below.
Why here
The only code that hashes signed context is src/lib/caller/LibContext.sol: build verifies each SignedContextV1 with personal_sign over keccak256 of the packed context words (LibHashNoAlloc.hashWords), and hash(SignedContextV1) / hash(SignedContextV1[]) fold the same words. rain.lib.hash has nothing to change for EIP-712; this repo does, and raindex supplies the domain in the version that adopts it.
What EIP-712 buys and does not buy
- Buys: a typed, wallet-displayable envelope (
\x19\x01 ‖ domainSeparator ‖ hashStruct), a domain the deploying contract chooses (every EIP712Domain field is optional, ERC-5267 publishes the chosen set), struct-type separation (a SignedContextV2 can never hash like a bare word list or a different struct), and an independent test oracle in forge-std (vm.eip712HashStruct, vm.eip712HashType, vm.eip712HashTypedData).
- Does not buy: replay protection. In Rain the binding a signed context needs (contract, order hash, nonce, expiry) is expressed in the context words and checked by the expression; that stays exactly where it is. The domain is for what the contract wants to bind at its level, possibly nothing beyond a name.
Spec sketch
- New versioned interface:
SignedContextV2 (same fields as V1) and renamed entry points that take it, so a V1 signature cannot reach a V2 verifier by accident; deployments are not upgradeable, so existing ones keep the V1 digest.
hashStruct(SignedContextV2) per EIP-712 over the context: keccak256(TYPEHASH ‖ keccak256(packed words)) — the inner keccak is exactly today's hashWords. Type string fixed as a literal (keccak256("…") folds at compile time; keccak256(bytes(STRING_CONSTANT)) allocates at runtime — measured).
build (V2) takes bytes32 domainSeparator as a parameter and verifies SignatureChecker.isValidSignatureNow(signer, MessageHashUtils.toTypedDataHash(domainSeparator, hashStruct), signature). OpenZeppelin 5.6.1 is already a dependency for MessageHashUtils/SignatureChecker.
- Tests: every hash against the forge-std cheatcode oracle from the type string; the existing length/value/signer negative tests re-pointed at V2; the domain-binding negative test (same words, different domain →
InvalidSignature).
- Follow-on (raindex): the adopting version inherits OZ
EIP712 (or builds a minimal domain) and passes _domainSeparatorV4() into build; new entry-point names there too; signing tooling for that version moves to signTypedData.
- Follow-on (rain.lib.hash): after this lands, the library's remaining contribution to the stack is the one
hashWords line; inline it and retire the dependency (separate issue there).
Measured
Type-hash word costs ~25-30 gas per struct hash; the no-alloc inline form and keccak256(abi.encode(TYPEHASH, …)) over pre-hashed members are within 25 gas of each other and of the current fold (solc 0.8.25, optimizer 100000, cancun).
Refs
rain.lib.hash #7 (closed in favour of this), #22 (cross-type collisions, documented under the type-fixed-slot restriction), rainlang.interface #132 (length-binding and decoder tests on V1).
Moved from rainlanguage/rain.lib.hash#7 (a bare link to EIP-712). The design survey behind this is archived at
/home/gildlab/artifacts/rain.lib.hash-2026-09-03/DESIGN-7.mdon the ops box; the parts that matter are below.Why here
The only code that hashes signed context is
src/lib/caller/LibContext.sol:buildverifies eachSignedContextV1withpersonal_signoverkeccak256of the packed context words (LibHashNoAlloc.hashWords), andhash(SignedContextV1)/hash(SignedContextV1[])fold the same words. rain.lib.hash has nothing to change for EIP-712; this repo does, and raindex supplies the domain in the version that adopts it.What EIP-712 buys and does not buy
\x19\x01 ‖ domainSeparator ‖ hashStruct), a domain the deploying contract chooses (everyEIP712Domainfield is optional, ERC-5267 publishes the chosen set), struct-type separation (aSignedContextV2can never hash like a bare word list or a different struct), and an independent test oracle in forge-std (vm.eip712HashStruct,vm.eip712HashType,vm.eip712HashTypedData).Spec sketch
SignedContextV2(same fields as V1) and renamed entry points that take it, so a V1 signature cannot reach a V2 verifier by accident; deployments are not upgradeable, so existing ones keep the V1 digest.hashStruct(SignedContextV2)per EIP-712 over the context:keccak256(TYPEHASH ‖ keccak256(packed words))— the inner keccak is exactly today'shashWords. Type string fixed as a literal (keccak256("…")folds at compile time;keccak256(bytes(STRING_CONSTANT))allocates at runtime — measured).build(V2) takesbytes32 domainSeparatoras a parameter and verifiesSignatureChecker.isValidSignatureNow(signer, MessageHashUtils.toTypedDataHash(domainSeparator, hashStruct), signature). OpenZeppelin 5.6.1 is already a dependency forMessageHashUtils/SignatureChecker.InvalidSignature).EIP712(or builds a minimal domain) and passes_domainSeparatorV4()intobuild; new entry-point names there too; signing tooling for that version moves tosignTypedData.hashWordsline; inline it and retire the dependency (separate issue there).Measured
Type-hash word costs ~25-30 gas per struct hash; the no-alloc inline form and
keccak256(abi.encode(TYPEHASH, …))over pre-hashed members are within 25 gas of each other and of the current fold (solc 0.8.25, optimizer 100000, cancun).Refs
rain.lib.hash #7 (closed in favour of this), #22 (cross-type collisions, documented under the type-fixed-slot restriction), rainlang.interface #132 (length-binding and decoder tests on V1).