diff --git a/src/interface/IInterpreterCallerV4.sol b/src/interface/IInterpreterCallerV4.sol index 7ba549f..d9abcf9 100644 --- a/src/interface/IInterpreterCallerV4.sol +++ b/src/interface/IInterpreterCallerV4.sol @@ -30,6 +30,60 @@ struct EvaluableV4 { bytes bytecode; } +/// @dev EIP-712 type of the data a `SignedContextV2` signature is over: the signer +/// and the context words. The `signature` field is not a member. +string constant SIGNED_CONTEXT_V2_TYPE = "SignedContextV2(address signer,bytes32[] context)"; + +/// @dev `keccak256` of `SIGNED_CONTEXT_V2_TYPE`, written as a literal so the hash +/// is computed at compile time. +bytes32 constant SIGNED_CONTEXT_V2_TYPEHASH = keccak256("SignedContextV2(address signer,bytes32[] context)"); + +/// Typed embodiment of some context data with associated signer and signature, +/// signed as EIP-712 typed data. The signature MUST be over the digest +/// `keccak256(abi.encodePacked(hex"1901", domainSeparator, hashStruct))` where +/// `hashStruct` is `LibContext.hashStruct`, i.e. +/// `keccak256(abi.encodePacked(SIGNED_CONTEXT_V2_TYPEHASH, signer, keccak256(abi.encodePacked(context))))`, +/// and `domainSeparator` is the EIP-712 domain separator of the calling +/// contract, passed by it to `LibContext.buildV2`. The calling contract chooses +/// its domain (which fields it has and their values) and MAY publish it per +/// ERC-5267; the same signed data under a different domain separator does not +/// verify. +/// +/// The domain separates this calling contract's signed contexts from other +/// domains. It is not replay protection: the calling contract (likely with the +/// help of `LibContext`) is responsible for ensuring the authenticity of the +/// signature, but not authorizing _who_ can sign. IN ADDITION to authorisation +/// of the signer to known-good entities the expression is also responsible for: +/// +/// - Enforcing the context is the expected data (e.g. a word identifying what +/// the signed context is for) +/// - Tracking and enforcing nonces if signed contexts are only usable one time +/// - Tracking and enforcing uniqueness of signed data if relevant +/// - Checking and enforcing expiry times if present and relevant in the context +/// - Many other potential constraints that expressions may want to enforce +/// +/// EIP-1271 smart contract signatures are supported in addition to EOA +/// signatures via. the Open Zeppelin `SignatureChecker` library, which is +/// wrapped by `LibContext.buildV2`. As smart contract signatures are checked +/// onchain they CAN BE REVOKED AT ANY MOMENT as the smart contract can simply +/// return `false` when it previously returned `true`. +/// +/// A `SignedContextV1` signature (EIP-191 `personal_sign` over the context +/// hash) is not a valid `SignedContextV2` signature, and vice versa. +/// +/// @param signer The account that produced the signature. Part of the signed +/// data, so a signature verifies for exactly one signer, for smart contract +/// signers too. +/// @param context The signed data in a format that can be merged into a +/// 2-dimensional context matrix as-is. +/// @param signature The EIP-712 signature over `signer` and `context` under +/// the calling contract's domain. Not part of the signed data. +struct SignedContextV2 { + address signer; + bytes32[] context; + bytes signature; +} + /// @title IInterpreterCallerV4 /// @notice A contract that calls an `IInterpreterV4` via. `eval4`. There are /// near zero requirements on a caller other than: diff --git a/src/lib/caller/LibContext.sol b/src/lib/caller/LibContext.sol index bbb3a12..34364c6 100644 --- a/src/lib/caller/LibContext.sol +++ b/src/lib/caller/LibContext.sol @@ -11,7 +11,9 @@ import { SignedContextV1, SIGNED_CONTEXT_SIGNER_OFFSET, SIGNED_CONTEXT_SIGNATURE_OFFSET, - SIGNED_CONTEXT_CONTEXT_OFFSET + SIGNED_CONTEXT_CONTEXT_OFFSET, + SignedContextV2, + SIGNED_CONTEXT_V2_TYPEHASH } from "../../interface/IInterpreterCallerV4.sol"; /// Thrown when the ith signature from a list of signed contexts is invalid. @@ -221,4 +223,102 @@ library LibContext { return context; } } + + /// EIP-712 `hashStruct` of a `SignedContextV2`: + /// `keccak256(abi.encodePacked(SIGNED_CONTEXT_V2_TYPEHASH, signer, keccak256(abi.encodePacked(context))))`. + /// The context is hashed as its packed 32-byte words with no length + /// prefix, which is the EIP-712 encoding of a `bytes32[]` member. The + /// `signature` field is not part of the hash. The three words are written + /// to the memory past the free memory pointer, which is not moved, so + /// nothing is allocated. + /// @param signedContext The signed context to hash. + /// @return hashed The EIP-712 struct hash of `signedContext`. + function hashStruct(SignedContextV2 memory signedContext) internal pure returns (bytes32 hashed) { + bytes32 typeHash = SIGNED_CONTEXT_V2_TYPEHASH; + address signer = signedContext.signer; + bytes32[] memory context = signedContext.context; + assembly ("memory-safe") { + let contextHash := keccak256(add(context, 0x20), mul(mload(context), 0x20)) + let ptr := mload(0x40) + mstore(ptr, typeHash) + mstore(add(ptr, 0x20), signer) + mstore(add(ptr, 0x40), contextHash) + hashed := keccak256(ptr, 0x60) + } + } + + /// Builds a standard 2-dimensional context array from base, calling and + /// signed contexts, as `build` does, with each `SignedContextV2` verified + /// as EIP-712 typed data under `domainSeparator`. The returned matrix has + /// the same layout as `build`: column 0 is `LibContext.base()`, then the + /// `baseContext` columns, then (only if there are signed contexts) a + /// column of the signers in order and one column per signed context. + /// + /// @param baseContext Anything the calling contract can provide which MAY + /// include input from the `msg.sender` of the calling contract. The default + /// base context from `LibContext.base()` DOES NOT need to be provided by the + /// caller, this matrix MAY be empty and will be simply merged into the final + /// context. The base context matrix MUST contain a consistent number of + /// columns from the calling contract so that the expression can always + /// predict how many unsigned columns there will be when it runs. + /// @param signedContexts Signed contexts are provided by the `msg.sender` + /// but signed by a third party. Each signature is verified for its + /// `signer` against + /// `MessageHashUtils.toTypedDataHash(domainSeparator, hashStruct(signedContext))` + /// through `SignatureChecker`, so EOA and ERC-1271 signers are both + /// supported. REVERTS with `InvalidSignature(i)` at the first `i` that + /// does not verify. The expression (author) defines _who_ may sign, and + /// the binding of the signed words to a particular use (nonces, expiries, + /// what the words are for) is expressed in the context words and checked + /// by the expression, as with `build`. The `msg.sender` can provide an + /// arbitrary number of signed contexts so expressions DO NOT know exactly + /// how many columns there are. + /// @param domainSeparator The EIP-712 domain separator of the calling + /// contract. This library computes no domain and fixes no domain fields. + /// The same signed data under a different domain separator does not + /// verify. + /// @return The fully assembled context matrix, laid out as `build`. + function buildV2(bytes32[][] memory baseContext, SignedContextV2[] memory signedContexts, bytes32 domainSeparator) + internal + view + returns (bytes32[][] memory) + { + unchecked { + bytes32[] memory signers = new bytes32[](signedContexts.length); + + // - LibContext.base() + whatever we are provided. + // - signed contexts + signers if they exist else nothing. + uint256 contextLength = 1 + baseContext.length + (signedContexts.length > 0 ? signedContexts.length + 1 : 0); + + bytes32[][] memory context = new bytes32[][](contextLength); + uint256 offset = 0; + context[offset] = LibContext.base(); + + for (uint256 i = 0; i < baseContext.length; i++) { + offset++; + context[offset] = baseContext[i]; + } + + if (signedContexts.length > 0) { + offset++; + context[offset] = signers; + + for (uint256 i = 0; i < signedContexts.length; i++) { + if (!SignatureChecker.isValidSignatureNow( + signedContexts[i].signer, + MessageHashUtils.toTypedDataHash(domainSeparator, hashStruct(signedContexts[i])), + signedContexts[i].signature + )) { + revert InvalidSignature(i); + } + + signers[i] = bytes32(uint256(uint160(signedContexts[i].signer))); + offset++; + context[offset] = signedContexts[i].context; + } + } + + return context; + } + } } diff --git a/test/lib/caller/LibSignedContextV2TypedData.sol b/test/lib/caller/LibSignedContextV2TypedData.sol new file mode 100644 index 0000000..59de2b8 --- /dev/null +++ b/test/lib/caller/LibSignedContextV2TypedData.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Vm} from "forge-std-1.16.2/src/Vm.sol"; +import {SignedContextV2, SIGNED_CONTEXT_V2_TYPE} from "src/interface/IInterpreterCallerV4.sol"; + +/// The EIP-712 message of a `SignedContextV2`: its typed members, without the +/// signature. ABI-encoded, this is what the struct hash cheatcode decodes. +struct SignedContextV2TypedData { + address signer; + bytes32[] context; +} + +/// An EIP-712 domain with the four standard fields. +struct EIP712Domain { + string name; + string version; + uint256 chainId; + address verifyingContract; +} + +/// EIP-712 oracle values for `SignedContextV2`, all derived by forge-std +/// cheatcodes from the type strings and EIP-712 JSON. Nothing here hashes +/// anything itself. +library LibSignedContextV2TypedData { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + string constant EIP712_DOMAIN_TYPE = + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"; + + /// ABI encoding of the typed members of `signedContext`. + function typedData(SignedContextV2 memory signedContext) internal pure returns (bytes memory) { + return abi.encode(SignedContextV2TypedData({signer: signedContext.signer, context: signedContext.context})); + } + + /// `hashStruct(signedContext)` from the cheatcode. + function hashStruct(SignedContextV2 memory signedContext) internal pure returns (bytes32) { + return vm.eip712HashStruct(SIGNED_CONTEXT_V2_TYPE, typedData(signedContext)); + } + + /// `hashStruct(domain)` from the cheatcode. + function domainSeparator(EIP712Domain memory domain) internal pure returns (bytes32) { + return vm.eip712HashStruct(EIP712_DOMAIN_TYPE, abi.encode(domain)); + } + + /// JSON array of the context words as 32-byte hex strings. + function contextJson(bytes32[] memory context) internal pure returns (string memory words) { + words = "["; + for (uint256 i = 0; i < context.length; i++) { + words = string.concat(words, i == 0 ? "" : ",", "\"", vm.toString(context[i]), "\""); + } + words = string.concat(words, "]"); + } + + /// EIP-712 JSON (`types`, `primaryType`, `domain`, `message`) for `signer` + /// and `context` under `domain`, as `eth_signTypedData_v4` takes it. + function json(EIP712Domain memory domain, address signer, bytes32[] memory context) + internal + pure + returns (string memory) + { + return string.concat( + "{\"types\":{\"EIP712Domain\":[{\"name\":\"name\",\"type\":\"string\"}," + "{\"name\":\"version\",\"type\":\"string\"},{\"name\":\"chainId\",\"type\":\"uint256\"}," + "{\"name\":\"verifyingContract\",\"type\":\"address\"}]," + "\"SignedContextV2\":[{\"name\":\"signer\",\"type\":\"address\"},{\"name\":\"context\",\"type\":\"bytes32[]\"}]}," + "\"primaryType\":\"SignedContextV2\",", + "\"domain\":{\"name\":\"", + domain.name, + "\",\"version\":\"", + domain.version, + "\",\"chainId\":", + vm.toString(domain.chainId), + ",\"verifyingContract\":\"", + vm.toString(domain.verifyingContract), + "\"},", + "\"message\":{\"signer\":\"", + vm.toString(signer), + "\",\"context\":", + contextJson(context), + "}}" + ); + } + + /// The digest a wallet signs for `signer` and `context` under `domain`, + /// from the cheatcode over the JSON. + function digest(EIP712Domain memory domain, address signer, bytes32[] memory context) + internal + pure + returns (bytes32) + { + return vm.eip712HashTypedData(json(domain, signer, context)); + } + + /// An EOA signature by `privateKey` over `digest(domain, vm.addr(privateKey), context)`. + function sign(uint256 privateKey, EIP712Domain memory domain, bytes32[] memory context) + internal + pure + returns (bytes memory) + { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest(domain, vm.addr(privateKey), context)); + return abi.encodePacked(r, s, v); + } +} diff --git a/test/src/lib/caller/LibContext.buildV2.t.sol b/test/src/lib/caller/LibContext.buildV2.t.sol new file mode 100644 index 0000000..9e1e4fd --- /dev/null +++ b/test/src/lib/caller/LibContext.buildV2.t.sol @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std-1.16.2/src/Test.sol"; +import {LibContext, MessageHashUtils, SignedContextV1, InvalidSignature} from "src/lib/caller/LibContext.sol"; +import {SignedContextV2} from "src/interface/IInterpreterCallerV4.sol"; +import {LibSignedContextV2TypedData, EIP712Domain} from "test/lib/caller/LibSignedContextV2TypedData.sol"; +import {LibContextSlow} from "./LibContextSlow.sol"; + +contract LibContextBuildV2Test is Test { + /// Private key of the signer used throughout. + uint256 constant SIGNER_PK = 0x5163; + /// Private key of a second signer. + uint256 constant OTHER_PK = 0xB0B; + /// Largest fuzzed context, in words, so the JSON the digests come from + /// stays small. + uint256 constant MAX_WORDS = 16; + + function buildV2External( + bytes32[][] memory baseContext, + SignedContextV2[] memory signedContexts, + bytes32 domainSeparator + ) external view returns (bytes32[][] memory) { + return LibContext.buildV2(baseContext, signedContexts, domainSeparator); + } + + function buildExternal(bytes32[][] memory baseContext, SignedContextV1[] memory signedContexts) + external + view + returns (bytes32[][] memory) + { + return LibContext.build(baseContext, signedContexts); + } + + /// The domain the signed contexts are verified under. + function domain() internal pure returns (EIP712Domain memory) { + return EIP712Domain("LibContextBuildV2Test", "1", 1, 0x1234567890123456789012345678901234567890); + } + + function signingDomainSeparator() internal pure returns (bytes32) { + return LibSignedContextV2TypedData.domainSeparator(domain()); + } + + /// `words` truncated to at most `MAX_WORDS`. + function bounded(bytes32[] memory words) internal pure returns (bytes32[] memory) { + if (words.length > MAX_WORDS) { + assembly ("memory-safe") { + mstore(words, MAX_WORDS) + } + } + return words; + } + + /// One signed context from `pk` presenting `context` under a signature + /// over `signedWords` in `signingDomain`. + function signedContextsFor( + uint256 pk, + EIP712Domain memory signingDomain, + bytes32[] memory context, + bytes32[] memory signedWords + ) internal pure returns (SignedContextV2[] memory) { + SignedContextV2[] memory signedContexts = new SignedContextV2[](1); + signedContexts[0] = SignedContextV2({ + signer: vm.addr(pk), + context: context, + signature: LibSignedContextV2TypedData.sign(pk, signingDomain, signedWords) + }); + return signedContexts; + } + + /// One signed context from `pk` presenting and signing `context` in the + /// test domain. + function signedContextsFor(uint256 pk, bytes32[] memory context) internal pure returns (SignedContextV2[] memory) { + return signedContextsFor(pk, domain(), context, context); + } + + function words1(bytes32 x) internal pure returns (bytes32[] memory words) { + words = new bytes32[](1); + words[0] = x; + } + + function words2(bytes32 x, bytes32 y) internal pure returns (bytes32[] memory words) { + words = new bytes32[](2); + words[0] = x; + words[1] = y; + } + + function assertEqContext(bytes32[][] memory expected, bytes32[][] memory actual) internal pure { + assertEq(expected.length, actual.length, "column count"); + for (uint256 i = 0; i < expected.length; i++) { + assertEq(expected[i], actual[i]); + } + } + + /// A signature over the EIP-712 digest of the presented words in the + /// domain `buildV2` is given builds the context, laid out as the + /// reference, with the signer in the signers column and the words as the + /// signed column. + /// forge-config: default.fuzz.runs = 100 + function testBuildV2ValidSignatureBuilds(bytes32[][] memory base, bytes32[] memory words) external view { + words = bounded(words); + SignedContextV2[] memory signedContexts = signedContextsFor(SIGNER_PK, words); + + bytes32[][] memory actual = LibContext.buildV2(base, signedContexts, signingDomainSeparator()); + assertEqContext(LibContextSlow.buildStructureSlow(base, signedContexts), actual); + + assertEq(actual.length, 1 + base.length + 2); + assertEq(actual[1 + base.length], words1(bytes32(uint256(uint160(vm.addr(SIGNER_PK)))))); + assertEq(actual[2 + base.length], words); + } + + /// Several signed contexts, each from its own signer, are all verified + /// under the one domain separator and laid out in order. + function testBuildV2SeveralSignedContextsBuild(bytes32 x, bytes32 y, bytes32 z) external view { + SignedContextV2[] memory signedContexts = new SignedContextV2[](2); + signedContexts[0] = signedContextsFor(SIGNER_PK, words2(x, y))[0]; + signedContexts[1] = signedContextsFor(OTHER_PK, words1(z))[0]; + + bytes32[][] memory actual = LibContext.buildV2(new bytes32[][](0), signedContexts, signingDomainSeparator()); + assertEqContext(LibContextSlow.buildStructureSlow(new bytes32[][](0), signedContexts), actual); + + assertEq(actual.length, 4); + assertEq( + actual[1], + words2(bytes32(uint256(uint160(vm.addr(SIGNER_PK)))), bytes32(uint256(uint160(vm.addr(OTHER_PK))))) + ); + assertEq(actual[2], words2(x, y)); + assertEq(actual[3], words1(z)); + } + + /// With no signed contexts the domain separator plays no part and the + /// result is the base context plus the caller's columns, as `build` + /// returns it. + /// forge-config: default.fuzz.runs = 100 + function testBuildV2ZeroSignedContexts(bytes32[][] memory base, bytes32 anyDomainSeparator) external view { + SignedContextV2[] memory signedContexts = new SignedContextV2[](0); + + bytes32[][] memory actual = LibContext.buildV2(base, signedContexts, anyDomainSeparator); + assertEqContext(LibContextSlow.buildStructureSlow(base, signedContexts), actual); + assertEqContext(LibContext.build(base, new SignedContextV1[](0)), actual); + assertEq(actual.length, 1 + base.length); + } + + /// First signature valid, second invalid: the revert names index 1. + function testBuildV2InvalidSignatureSecondIndexReverts(bytes32 x) external { + SignedContextV2[] memory signedContexts = new SignedContextV2[](2); + signedContexts[0] = signedContextsFor(SIGNER_PK, words1(x))[0]; + signedContexts[1] = SignedContextV2({signer: address(0xdead), context: words1(x), signature: new bytes(65)}); + + vm.expectRevert(abi.encodeWithSelector(InvalidSignature.selector, uint256(1))); + this.buildV2External(new bytes32[][](0), signedContexts, signingDomainSeparator()); + } + + /// A signature over `[x]` does not authenticate `[y]`. + function testBuildV2WrongWordReverts(bytes32 x, bytes32 y) external { + vm.assume(x != y); + SignedContextV2[] memory signedContexts = signedContextsFor(SIGNER_PK, domain(), words1(y), words1(x)); + + vm.expectRevert(abi.encodeWithSelector(InvalidSignature.selector, uint256(0))); + this.buildV2External(new bytes32[][](0), signedContexts, signingDomainSeparator()); + } + + /// A signature over `[x]` does not authenticate `[x, y]`. + function testBuildV2AppendedWordReverts(bytes32 x, bytes32 y) external { + SignedContextV2[] memory signedContexts = signedContextsFor(SIGNER_PK, domain(), words2(x, y), words1(x)); + + vm.expectRevert(abi.encodeWithSelector(InvalidSignature.selector, uint256(0))); + this.buildV2External(new bytes32[][](0), signedContexts, signingDomainSeparator()); + } + + /// A signature over `[x]` does not authenticate `[x, 0]`. + function testBuildV2AppendedZeroReverts(bytes32 x) external { + SignedContextV2[] memory signedContexts = signedContextsFor(SIGNER_PK, domain(), words2(x, 0), words1(x)); + + vm.expectRevert(abi.encodeWithSelector(InvalidSignature.selector, uint256(0))); + this.buildV2External(new bytes32[][](0), signedContexts, signingDomainSeparator()); + } + + /// A signature over `[x, y]` does not authenticate its prefix `[x]`. + function testBuildV2TruncatedReverts(bytes32 x, bytes32 y) external { + SignedContextV2[] memory signedContexts = signedContextsFor(SIGNER_PK, domain(), words1(x), words2(x, y)); + + vm.expectRevert(abi.encodeWithSelector(InvalidSignature.selector, uint256(0))); + this.buildV2External(new bytes32[][](0), signedContexts, signingDomainSeparator()); + } + + /// A signature over `[x]` does not authenticate `[]`. + function testBuildV2EmptiedReverts(bytes32 x) external { + SignedContextV2[] memory signedContexts = signedContextsFor(SIGNER_PK, domain(), new bytes32[](0), words1(x)); + + vm.expectRevert(abi.encodeWithSelector(InvalidSignature.selector, uint256(0))); + this.buildV2External(new bytes32[][](0), signedContexts, signingDomainSeparator()); + } + + /// A signature by one signer over `words` presented as another signer's + /// does not verify: the signer is in the signed data and is the account + /// the signature is checked for. + function testBuildV2WrongSignerReverts(bytes32[] memory words, uint256 otherPk) external { + words = bounded(words); + otherPk = bound(otherPk, 1, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364140); + vm.assume(otherPk != SIGNER_PK); + + SignedContextV2[] memory signedContexts = signedContextsFor(SIGNER_PK, words); + signedContexts[0].signer = vm.addr(otherPk); + + vm.expectRevert(abi.encodeWithSelector(InvalidSignature.selector, uint256(0))); + this.buildV2External(new bytes32[][](0), signedContexts, signingDomainSeparator()); + } + + /// An empty signature does not verify. + function testBuildV2EmptySignatureReverts(bytes32[] memory words) external { + SignedContextV2[] memory signedContexts = new SignedContextV2[](1); + signedContexts[0] = SignedContextV2({signer: vm.addr(SIGNER_PK), context: words, signature: ""}); + + vm.expectRevert(abi.encodeWithSelector(InvalidSignature.selector, uint256(0))); + this.buildV2External(new bytes32[][](0), signedContexts, signingDomainSeparator()); + } + + /// The same words signed in one domain do not verify under any other + /// domain separator. + function testBuildV2DifferentDomainSeparatorReverts(bytes32[] memory words, bytes32 otherDomainSeparator) external { + words = bounded(words); + vm.assume(otherDomainSeparator != signingDomainSeparator()); + SignedContextV2[] memory signedContexts = signedContextsFor(SIGNER_PK, words); + + vm.expectRevert(abi.encodeWithSelector(InvalidSignature.selector, uint256(0))); + this.buildV2External(new bytes32[][](0), signedContexts, otherDomainSeparator); + } + + /// The same words signed for one verifying contract do not verify under + /// the domain of another, and likewise across chain ids. + function testBuildV2DifferentDomainFieldsRevert( + bytes32[] memory words, + uint64 otherChainId, + address otherVerifyingContract + ) external { + words = bounded(words); + EIP712Domain memory signingDomain = domain(); + vm.assume(otherChainId != signingDomain.chainId); + vm.assume(otherVerifyingContract != signingDomain.verifyingContract); + SignedContextV2[] memory signedContexts = signedContextsFor(SIGNER_PK, words); + + EIP712Domain memory otherContract = domain(); + otherContract.verifyingContract = otherVerifyingContract; + vm.expectRevert(abi.encodeWithSelector(InvalidSignature.selector, uint256(0))); + this.buildV2External( + new bytes32[][](0), signedContexts, LibSignedContextV2TypedData.domainSeparator(otherContract) + ); + + EIP712Domain memory otherChain = domain(); + otherChain.chainId = otherChainId; + vm.expectRevert(abi.encodeWithSelector(InvalidSignature.selector, uint256(0))); + this.buildV2External( + new bytes32[][](0), signedContexts, LibSignedContextV2TypedData.domainSeparator(otherChain) + ); + } + + /// A `SignedContextV1` signature, `personal_sign` over the keccak of the + /// packed words, does not verify as a `SignedContextV2` signature over the + /// same words. + function testBuildV2PersonalSignSignatureReverts(bytes32[] memory words) external { + bytes32 personalSignDigest = MessageHashUtils.toEthSignedMessageHash(keccak256(abi.encodePacked(words))); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_PK, personalSignDigest); + + SignedContextV1[] memory v1 = new SignedContextV1[](1); + v1[0] = SignedContextV1({signer: vm.addr(SIGNER_PK), context: words, signature: abi.encodePacked(r, s, v)}); + assertEq(LibContext.build(new bytes32[][](0), v1).length, 3, "verifies as V1"); + + SignedContextV2[] memory signedContexts = new SignedContextV2[](1); + signedContexts[0] = SignedContextV2({signer: v1[0].signer, context: words, signature: v1[0].signature}); + + vm.expectRevert(abi.encodeWithSelector(InvalidSignature.selector, uint256(0))); + this.buildV2External(new bytes32[][](0), signedContexts, signingDomainSeparator()); + } + + /// A `SignedContextV2` signature does not verify as a `SignedContextV1` + /// signature over the same words. + function testBuildV2SignatureDoesNotVerifyAsV1(bytes32[] memory words) external { + words = bounded(words); + SignedContextV2[] memory signedContexts = signedContextsFor(SIGNER_PK, words); + assertEq( + LibContext.buildV2(new bytes32[][](0), signedContexts, signingDomainSeparator()).length, 3, "verifies as V2" + ); + + SignedContextV1[] memory v1 = new SignedContextV1[](1); + v1[0] = + SignedContextV1({signer: signedContexts[0].signer, context: words, signature: signedContexts[0].signature}); + + vm.expectRevert(abi.encodeWithSelector(InvalidSignature.selector, uint256(0))); + this.buildExternal(new bytes32[][](0), v1); + } +} diff --git a/test/src/lib/caller/LibContext.hashStruct.t.sol b/test/src/lib/caller/LibContext.hashStruct.t.sol new file mode 100644 index 0000000..12bbbc1 --- /dev/null +++ b/test/src/lib/caller/LibContext.hashStruct.t.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std-1.16.2/src/Test.sol"; +import {LibContext, MessageHashUtils} from "src/lib/caller/LibContext.sol"; +import { + SignedContextV2, + SIGNED_CONTEXT_V2_TYPE, + SIGNED_CONTEXT_V2_TYPEHASH +} from "src/interface/IInterpreterCallerV4.sol"; +import {LibSignedContextV2TypedData, EIP712Domain} from "test/lib/caller/LibSignedContextV2TypedData.sol"; + +contract LibContextHashStructTest is Test { + /// The type hash literal is the keccak of the type string: as the + /// cheatcode canonicalises the string, as `keccak256` of the string + /// constant, and as `cast keccak` of the string reports it. + function testSignedContextV2TypeHash() external pure { + assertEq(SIGNED_CONTEXT_V2_TYPEHASH, vm.eip712HashType(SIGNED_CONTEXT_V2_TYPE)); + assertEq(SIGNED_CONTEXT_V2_TYPEHASH, keccak256(bytes(SIGNED_CONTEXT_V2_TYPE))); + assertEq(SIGNED_CONTEXT_V2_TYPEHASH, 0x6ec4dff745ec96dbde88c5d41f8aa5ac13aa1664afb47b8406b44fa945a650c8); + } + + /// `hashStruct` is the EIP-712 struct hash of the signer and context. + function testHashStructMatchesCheatcode(SignedContextV2 memory signedContext) external pure { + assertEq(LibContext.hashStruct(signedContext), LibSignedContextV2TypedData.hashStruct(signedContext)); + } + + /// An empty context is the EIP-712 encoding of an empty array, and the + /// struct hash still matches. + function testHashStructEmptyContextMatchesCheatcode(address signer, bytes memory signature) external pure { + SignedContextV2 memory signedContext = SignedContextV2(signer, new bytes32[](0), signature); + assertEq(LibContext.hashStruct(signedContext), LibSignedContextV2TypedData.hashStruct(signedContext)); + } + + /// The signature is not part of the signed data. + function testHashStructIgnoresSignature( + address signer, + bytes32[] memory context, + bytes memory signatureA, + bytes memory signatureB + ) external pure { + assertEq( + LibContext.hashStruct(SignedContextV2(signer, context, signatureA)), + LibContext.hashStruct(SignedContextV2(signer, context, signatureB)) + ); + } + + /// The signer is part of the signed data. + function testHashStructBindsSigner(address signerA, address signerB, bytes32[] memory context) external pure { + vm.assume(signerA != signerB); + assertNotEq( + LibContext.hashStruct(SignedContextV2(signerA, context, "")), + LibContext.hashStruct(SignedContextV2(signerB, context, "")) + ); + } + + /// Every context word is part of the signed data. + function testHashStructBindsContextWord(address signer, bytes32[] memory context, uint256 i, bytes32 other) + external + pure + { + vm.assume(context.length > 0); + i = bound(i, 0, context.length - 1); + vm.assume(context[i] != other); + bytes32 before = LibContext.hashStruct(SignedContextV2(signer, context, "")); + context[i] = other; + assertNotEq(before, LibContext.hashStruct(SignedContextV2(signer, context, ""))); + } + + /// The number of context words is part of the signed data: appending a + /// word, including a zero word, changes the hash. + function testHashStructBindsContextLength(address signer, bytes32[] memory context, bytes32 extra) external pure { + bytes32 before = LibContext.hashStruct(SignedContextV2(signer, context, "")); + + bytes32[] memory appended = new bytes32[](context.length + 1); + for (uint256 i = 0; i < context.length; i++) { + appended[i] = context[i]; + } + appended[context.length] = extra; + assertNotEq(before, LibContext.hashStruct(SignedContextV2(signer, appended, ""))); + + appended[context.length] = 0; + assertNotEq(before, LibContext.hashStruct(SignedContextV2(signer, appended, ""))); + } + + /// `hashStruct` leaves the free memory pointer and the zero slot as they + /// were. + function testHashStructDoesNotAllocate(SignedContextV2 memory signedContext) external pure { + uint256 freeMemoryPointerBefore; + bytes32 zeroSlotBefore; + assembly ("memory-safe") { + freeMemoryPointerBefore := mload(0x40) + zeroSlotBefore := mload(0x60) + } + + LibContext.hashStruct(signedContext); + + uint256 freeMemoryPointerAfter; + bytes32 zeroSlotAfter; + assembly ("memory-safe") { + freeMemoryPointerAfter := mload(0x40) + zeroSlotAfter := mload(0x60) + } + assertEq(freeMemoryPointerAfter, freeMemoryPointerBefore); + assertEq(zeroSlotAfter, zeroSlotBefore); + assertEq(zeroSlotAfter, bytes32(0)); + } + + /// The digest `buildV2` verifies, the typed data hash of the domain + /// separator and `hashStruct`, is the digest a wallet produces from the + /// EIP-712 JSON of the same domain and message. + /// forge-config: default.fuzz.runs = 256 + function testTypedDataDigestMatchesCheatcode( + address signer, + bytes32[] memory context, + uint64 chainId, + address verifyingContract + ) external pure { + EIP712Domain memory domain = EIP712Domain("LibContextHashStructTest", "1", chainId, verifyingContract); + bytes32 domainSeparator = LibSignedContextV2TypedData.domainSeparator(domain); + assertEq( + domainSeparator, + MessageHashUtils.toDomainSeparator(bytes1(0x0f), domain.name, domain.version, chainId, verifyingContract, 0) + ); + + SignedContextV2 memory signedContext = SignedContextV2(signer, context, ""); + assertEq( + MessageHashUtils.toTypedDataHash(domainSeparator, LibContext.hashStruct(signedContext)), + LibSignedContextV2TypedData.digest(domain, signer, context) + ); + } +} diff --git a/test/src/lib/caller/LibContextSlow.sol b/test/src/lib/caller/LibContextSlow.sol index c05aff8..aefcc5f 100644 --- a/test/src/lib/caller/LibContextSlow.sol +++ b/test/src/lib/caller/LibContextSlow.sol @@ -6,7 +6,7 @@ import {LibHashNoAlloc, HASH_NIL} from "rain-lib-hash-0.1.0/src/LibHashNoAlloc.s import {LibCast} from "rain-lib-typecast-0.1.4/src/LibCast.sol"; import {LibUint256Array} from "rain-solmem-0.1.26/src/lib/LibUint256Array.sol"; -import {SignedContextV1} from "src/interface/IInterpreterCallerV4.sol"; +import {SignedContextV1, SignedContextV2} from "src/interface/IInterpreterCallerV4.sol"; library LibContextSlow { using LibUint256Array for uint256; @@ -65,4 +65,39 @@ library LibContextSlow { return context; } + + function buildStructureSlow(bytes32[][] memory baseContext, SignedContextV2[] memory signedContexts) + internal + view + returns (bytes32[][] memory) + { + uint256 signedLen = signedContexts.length > 0 ? signedContexts.length + 1 : 0; + bytes32[][] memory context = new bytes32[][](1 + baseContext.length + signedLen); + context[0] = new bytes32[](2); + context[0][0] = bytes32(uint256(uint160(address(msg.sender)))); + context[0][1] = bytes32(uint256(uint160(address(this)))); + + uint256 offset = 1; + uint256 i = 0; + for (; i < baseContext.length; i++) { + context[i + offset] = baseContext[i]; + } + offset = offset + i; + + if (signedContexts.length > 0) { + bytes32[] memory signers = new bytes32[](signedContexts.length); + for (i = 0; i < signedContexts.length; i++) { + signers[i] = bytes32(uint256(uint160(signedContexts[i].signer))); + } + context[offset] = signers; + offset = offset + 1; + + i = 0; + for (; i < signedContexts.length; i++) { + context[i + offset] = signedContexts[i].context; + } + } + + return context; + } }