Skip to content

EIP-712 signed context: SignedContextV2 with a caller-chosen domain, hashStruct over the context words, forge-std oracle tests #133

Description

@thedavidmeister

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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions