diff --git a/ERCS/erc-7920.md b/ERCS/erc-7920.md new file mode 100644 index 00000000000..7347070c37d --- /dev/null +++ b/ERCS/erc-7920.md @@ -0,0 +1,335 @@ +--- +eip: 7920 +title: Composite EIP-712 Signatures +description: A scheme for signing multiple typed-data messages with a single signature +author: Sola Ogunsakin (@sola92) +discussions-to: https://ethereum-magicians.org/t/composite-eip-712-signatures/23266 +status: Draft +type: Standards Track +category: ERC +created: 2025-03-20 +requires: 20, 712 +--- + +## Abstract + +This ERC provides a standard for signing multiple typed-data messages with a single signature by encoding them into a Merkle tree. This allows components to independently verify messages, without requiring full knowledge of the others. It provides a significant UX improvement by reducing the number of signature prompts to one, while preserving the security and flexibility of the [EIP-712](./eip-712.md) standard. + +This ERC also gives applications the flexibility to verify messages in isolation, or in aggregate. This opens up new verification modalities: for e.g, an application can require that message (`x`) is only valid when signed in combination message (`y`). + +## Motivation + +As the ecosystem moves towards ETH-less transactions, users are often required to sign multiple off-chain messages in quick succession. Typically, a first signature is needed for a precise spend allowance (via Permit2, [ERC-2612](./eip-2612.md), etc.), followed by subsequent messages to direct the use of funds. This creates a frictional user experience as each signature requires a separate wallet interaction and creates confusion about what, in aggregate, is being approved. + +Current solutions have significant drawbacks: + +- **Pre-approving [ERC-20](./eip-20.md) allowance:** spend creates security vulnerabilities +- **Merging multiple messages into a single message:** prevents independent verifiability. Each message cannot be verified without knowledge of the entire batch +- **Separate signature requests:** creates friction in the user experience + +This ERC has the following objectives: + +### Single Signature + +A single signature should cover multiple messages + +### Isolated Verification + +Messages should be independently verifiable without knowledge of others + +### Human-readable + +Readability benefits of EIP-712 should be preserved. Giving wallets and users insight into what is being signed. + +## Specification + +### Overview + +The composite signature scheme uses a Merkle tree to hash multiple typed-data data messages together under a single root. The user signs only the Merkle root. The process is described below. + +### Generating a Composite Signature + +1. For a set of messages `[m₁, m₂, ..., mₙ]`, encode each using EIP-712's `encode` and compute its hash: + + ``` + hashₙ = keccak256(encode(mₙ)) + ``` + +2. Use these message hashes as leaf nodes in a Merkle tree and compute a `merkleRoot` + +3. Sign the merkle root. + + ``` + signature = sign(merkleRoot) + ``` + +### Verification Process + +To verify that an individual message `mₓ` was included in a composite signature: + +1. Verify the signature on the `merkleRoot`: + + ``` + recoveredSigner = ecrecover(merkleRoot, signature) + isValidSignature = (recoveredSigner == expectedSigner) + ``` + +2. Compute the leaf node for message `mₓ` and verify its path to the Merkle root, using the proof: + ``` + leaf = keccak256(encode(mₓ)) + isValidProof = _verifyMerkleProof(leaf, merkleProof, merkleRoot) + ``` + +Where `_verifyMerkleProof()` is defined as: + +```solidity +function _verifyMerkleProof( + bytes32 leaf, + bytes32[] calldata proof, + bytes32 merkleRoot +) internal pure returns (bool) { + bytes32 computedRoot = leaf; + for (uint256 i = 0; i < proof.length; ++i) { + if (computedRoot < proof[i]) { + computedRoot = keccak256(abi.encode(computedRoot, proof[i])); + } else { + computedRoot = keccak256(abi.encode(proof[i], computedRoot)); + } + } + + return computedRoot == merkleRoot; +} +``` + +The message is verified if and only if (1) and (2) succeed. + +``` +isVerified = isValidSignature && isValidProof +``` + +### Specification of `eth_signTypedData_v5` JSON RPC method. + +This ERC adds a new method `eth_signTypedData_v5` to Ethereum JSON-RPC. This method allows signing multiple typed data messages with a single signature using the specification described above. The signing account must be prior unlocked. + +This method returns: the signature, merkle root, and an array of proofs (each corresponding to an input message). + +#### Parameters + +1. `Address` - Signing account +2. `TypedData | TypedDataArray` - A single TypedData object or Array of `TypedData` objects from EIP-712. + +##### Returns + +```typescript +{ + signature: `0x${string}`; // Hex encoded 65 byte signature (same format as eth_sign) + merkleRoot: `0x${string}`; // 32 byte Merkle root as hex string + proofs: Array>; // Array of Merkle proofs (one for each input message) +} +``` + +##### Example + +Request: + +```json +{ + "jsonrpc": "2.0", + "method": "eth_signTypedData_v5", + "params": [ + "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826", + [ + { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "Person": [ + { + "name": "name", + "type": "string" + }, + { + "name": "wallet", + "type": "address" + } + ], + "Mail": [ + { + "name": "from", + "type": "Person" + }, + { + "name": "to", + "type": "Person" + }, + { + "name": "contents", + "type": "string" + } + ] + }, + "primaryType": "Mail", + "domain": { + "name": "Ether Mail", + "version": "1", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "message": { + "from": { + "name": "Cow", + "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" + }, + "to": { + "name": "Bob", + "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" + }, + "contents": "Hello, Bob!" + } + }, + { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "Transfer": [ + { + "name": "amount", + "type": "uint256" + }, + { + "name": "recipient", + "type": "address" + } + ] + }, + "primaryType": "Transfer", + "domain": { + "name": "Ether Mail", + "version": "1", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "message": { + "amount": "1000000000000000000", + "recipient": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" + } + } + ] + ], + "id": 1 +} +``` + +Result: + +```JavaScript +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "signature": "0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b915621c", + "merkleRoot": "0x7de103665e21d6c9d9f82ae59675443bd895ed42b571c7f952c2fdc1a5b6e8d2", + "proofs": [ + ["0x4bdbac3830d492ac3f4b0ef674786940fb33481b32392e88edafd45d507429f2"], + ["0x95be87f8abefcddc8116061a06b18906f32298a4644882d06baff852164858c6"] + ] + } +} +``` + +## Rationale + +The choice of using a Merkle tree to bundle messages provides the following additional benefits: + +### Efficient verification on-chain + +`_verifyMerkleProof` has a runtime of `O(log2(N))` where N is the number of messages that were signed. + +### Flexible Verification Modes + +Applications can require combination of messages be signed together to enhance security. + +### `N=1` backwards compatibility + +Merkle signature for single message bundles are equal to `eth_signTypedData_v4`. Requiring no onchain changes. + +## Backwards Compatibility + +When the number of message is one, `eth_signTypedData_v5` produces the same signature as `eth_signTypedData_v4` since `merkleRoot == keccak256(encode(message))`. This allows `eth_signTypedData_v5` to be a drop-in replacement for `eth_signTypedData_v4` with no changes to on-chain verification. + +## Reference Implementation + +### `eth_signTypedData_v5` + +Reference implementation of `eth_signTypedData_v5` can be found the [assets directory](../assets/eip-7920/src/eth_signTypedData_v5.ts). + +### Verifier + +Solidity implementation of a onchain verifier can be found the [assets directory](../assets/eip-7920/contracts/ExampleVerifier.sol). + +### Merkle + +Reference Merkle tree can be found in the [assets directory](../assets/eip-7920/src/merkle.ts). + +## Security Considerations + +### Replay Protection + +This ERC focuses on generating composite messages and verifying their signatures. It does not contain mechanisms to prevent replays. Developers **must** ensure their applications can handle receiving the same message twice. + +### Partial Message Verification + +During verification, care **must** be taken to ensure that **both** of these checks pass: + +1. EIP-712 signature on the Merkle root is valid +2. Merkle proof is valid against the root + +### User Understanding + +Wallets **must** communicate to users that they are signing multiple messages at once. Wallets **must** display of all message types before signing. + +To ensure batch signature requests are digestible, it is recommended to limit the maximum number of messages to 10. + +### Merkle Tree Construction + +Merkle tree should be constructed in a consistent manner. + +1. The hashing function **must** be `keccak256` +2. To ensure predictable/consistent proof sizes, implementations **must** pad leaves with zero hashes to reach next power of two to ensure balance. Let `n` be the number of messages. Before constructing the tree, compute the smallest `k` such that `2^(k-1) < n ≤ 2^k`. Insert zero hashes into the list of messages until list of messages is equal to `2^k`. +3. To ensure an implicit verification path, pairs **must** be sorted lexicographically before constructing parent hash. + +## Copyright + +Copyright and related rights waived via [CC0](../LICENSE.md). diff --git a/assets/erc-7920/contracts/ExampleVerifier.sol b/assets/erc-7920/contracts/ExampleVerifier.sol new file mode 100644 index 00000000000..2e245c6e180 --- /dev/null +++ b/assets/erc-7920/contracts/ExampleVerifier.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.20; + +error Unauthorized(); +error NotInTree(); + +contract ExampleVerifier { + bytes32 public immutable DOMAIN_SEPARATOR; + bytes32 private constant MESSAGE_TYPEHASH = + keccak256("PlaceOrder(bytes32 orderId, address user)"); + + constructor() { + DOMAIN_SEPARATOR = keccak256( + abi.encode( + keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ), + keccak256(bytes("MyApp")), + keccak256(bytes("1.0.0")), + block.chainid, + address(this) + ) + ); + } + + function placeOrder( + bytes32 orderId, + address user, + bytes calldata signature, + bytes32 merkleRoot, + bytes32[] calldata proof + ) public { + bytes32 message = keccak256( + abi.encode( + "\x19\x01", + DOMAIN_SEPARATOR, + keccak256(abi.encode(MESSAGE_TYPEHASH, orderId, user)) + ) + ); + + if ( + !_verifyCompositeSignature( + message, + proof, + merkleRoot, + signature, + user + ) + ) { + revert Unauthorized(); + } + + // DO STUFF + } + + function _verifyCompositeSignature( + bytes32 message, + bytes32[] calldata proof, + bytes32 merkleRoot, + bytes calldata signature, + address expectedSigner + ) internal view returns (bool) { + if (!_verifyMerkleProof(message, proof, merkleRoot)) { + revert NotInTree(); + } + + return _recover(merkleRoot, signature) == expectedSigner; + } + + function _verifyMerkleProof( + bytes32 leaf, + bytes32[] calldata proof, + bytes32 root + ) internal pure returns (bool) { + bytes32 computedRoot = leaf; + for (uint256 i = 0; i < proof.length; ++i) { + if (computedRoot < proof[i]) { + computedRoot = keccak256(abi.encode(computedRoot, proof[i])); + } else { + computedRoot = keccak256(abi.encode(proof[i], computedRoot)); + } + } + + return computedRoot == root; + } + + function _recover( + bytes32 digest, + bytes memory signature + ) internal pure returns (address) { + require(signature.length == 65, "Invalid signature length"); + + bytes32 r; + bytes32 s; + uint8 v; + + assembly { + r := mload(add(signature, 32)) + s := mload(add(signature, 64)) + v := byte(0, mload(add(signature, 96))) + } + + return ecrecover(digest, v, r, s); + } + + // Debug function to generate message + function debugGenerateMessageHash( + bytes32 orderId, + address user + ) public view returns (bytes32) { + return + keccak256( + abi.encode( + "\x19\x01", + DOMAIN_SEPARATOR, + keccak256(abi.encode(MESSAGE_TYPEHASH, orderId, user)) + ) + ); + } +} diff --git a/assets/erc-7920/src/eth_signTypedData_v5.ts b/assets/erc-7920/src/eth_signTypedData_v5.ts new file mode 100644 index 00000000000..707024e9e99 --- /dev/null +++ b/assets/erc-7920/src/eth_signTypedData_v5.ts @@ -0,0 +1,323 @@ +import { ethers } from "ethers"; +import { keccak256 } from "@ethersproject/keccak256"; +import { Eip712TypedData } from "web3"; +import { + ecrecover, + fromRpcSig, + publicToAddress, + bytesToHex, +} from "@ethereumjs/util"; +import * as sigUtil from "eth-sig-util"; + +import { MerkleTree } from "./merkle"; + +type MerkleProof = ReadonlyArray<`0x${string}`>; + +/** + * Signs multiple EIP-712 typed data messages with a single signature. + * + * This function creates a Merkle tree from the hashes of multiple EIP-712 typed data messages, + * then signs the Merkle root to produce a single signature that can validate any of the individual messages. + * + * @param args - The arguments for the function + * @param args.privateKey - The private key to sign with + * @param args.messages - Single message or a list of EIP-712 typed data messages to include in the composite signature + * @returns Object containing the signature, Merkle root, and proofs for each message + */ +async function eth_signTypedData_v5(args: { + readonly privateKey: Buffer; + readonly messages: Eip712TypedData | ReadonlyArray; +}): Promise<{ + readonly signature: `0x${string}`; + readonly merkleRoot: `0x${string}`; + readonly proofs: ReadonlyArray; +}> { + const { privateKey } = args; + const messages = Array.isArray(args.messages) + ? args.messages + : [args.messages]; + const messageHashes: ReadonlyArray = messages.map( + ({ message, domain, types }) => { + const { EIP712Domain, ...typesWithoutDomain } = types; + const hash = ethers.TypedDataEncoder.hash( + domain, + typesWithoutDomain, + message + ); + + return Buffer.from(hash.slice(2), "hex"); + } + ); + + const tree = new MerkleTree(messageHashes as Array); + + const merkleRoot = tree.getRoot(); + const wallet = new ethers.Wallet(`0x${privateKey.toString("hex")}`); + const signature = wallet.signingKey.sign(merkleRoot); + + const proofs: ReadonlyArray = messageHashes.map((hash) => + tree + .getProof(hash) + .map((proof) => `0x${proof.toString("hex")}` as `0x${string}`) + ); + + return { + signature: signature.serialized as `0x${string}`, + merkleRoot: `0x${merkleRoot.toString("hex")}`, + proofs, + }; +} + +/** + * Recovers the signer of a composite message. + * + * This function verifies that a message was included in a composite signature by: + * 1. Verifying the Merkle proof against the Merkle root + * 2. Recovering the signer from the composite signature + * + * @param args - The arguments for the function + * @param args.signature - The signature produced by eth_signTypedData_v5 + * @param args.merkleRoot - The Merkle root of all signed messages + * @param args.proof - The Merkle proof for the specific message being verified + * @param args.message - The EIP-712 typed data message to verify + * @returns The recovered signer address as a 0x-prefixed string, or undefined if the signature or proof is invalid + */ +function recoverCompositeTypedDataSig(args: { + readonly signature: `0x${string}`; + readonly merkleRoot: `0x${string}`; + readonly proof: MerkleProof; + readonly message: Eip712TypedData; +}): `0x${string}` | undefined { + const { signature, message } = args; + + const { EIP712Domain, ...typesWithoutDomain } = message.types; + const leafHex = ethers.TypedDataEncoder.hash( + message.domain, + typesWithoutDomain, + message.message + ); + const leaf = Buffer.from(leafHex.slice(2), "hex"); + + const proof = args.proof.map((d) => Buffer.from(d.slice(2), "hex")); + const merkleRoot = Buffer.from(args.merkleRoot.slice(2), "hex"); + + function _keccak256(data: Buffer): Buffer { + return Buffer.from(keccak256(data).slice(2), "hex"); + } + + let computedHash = leaf; + for (let i = 0; i < proof.length; i++) { + if (Buffer.compare(computedHash, proof[i]) == -1) { + computedHash = _keccak256(Buffer.concat([computedHash, proof[i]])); + } else { + computedHash = _keccak256(Buffer.concat([proof[i], computedHash])); + } + } + + if (Buffer.compare(computedHash, merkleRoot) != 0) { + return; + } + + const sigParams = fromRpcSig(signature); + const pubKey = ecrecover(merkleRoot, sigParams.v, sigParams.r, sigParams.s); + return bytesToHex(publicToAddress(pubKey)) as `0x${string}`; +} + +async function main() { + const messages: ReadonlyArray = [ + { + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" }, + ], + Mail: [ + { name: "from", type: "Person" }, + { name: "to", type: "Person" }, + { name: "contents", type: "string" }, + ], + Person: [ + { name: "name", type: "string" }, + { name: "wallet", type: "address" }, + ], + }, + primaryType: "Mail", + domain: { + name: "Ether Mail", + version: "1", + chainId: 1, + verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC", + }, + message: { + from: { + name: "Cow", + wallet: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826", + }, + to: { + name: "Bob", + wallet: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB", + }, + contents: "Hello, Bob!", + }, + }, + { + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" }, + ], + Transfer: [ + { name: "amount", type: "uint256" }, + { name: "recipient", type: "address" }, + ], + }, + primaryType: "Transfer", + domain: { + name: "Ether Mail", + version: "1", + chainId: 1, + verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC", + }, + message: { + amount: "1000000000000000000", + recipient: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB", + }, + }, + { + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" }, + ], + Transfer: [ + { name: "amount", type: "uint256" }, + { name: "recipient", type: "address" }, + ], + }, + primaryType: "Transfer", + domain: { + name: "Ether Mail", + version: "1", + chainId: 1, + verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC", + }, + message: { + amount: "2000000000000000000", + recipient: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB", + }, + }, + { + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" }, + ], + Transfer: [ + { name: "amount", type: "uint256" }, + { name: "recipient", type: "address" }, + ], + }, + primaryType: "Transfer", + domain: { + name: "Ether Mail", + version: "1", + chainId: 1, + verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC", + }, + message: { + amount: "3000000000000000000", + recipient: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB", + }, + }, + ]; + + const nonMessage = { + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" }, + ], + Transfer: [ + { name: "amount", type: "uint256" }, + { name: "recipient", type: "address" }, + ], + }, + primaryType: "Transfer", + domain: { + name: "Ether Mail", + version: "1", + chainId: 1, + verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC", + }, + message: { + amount: "4000000000000000000", + recipient: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB", + }, + }; + + const wallet = ethers.Wallet.createRandom(); + const result = await eth_signTypedData_v5({ + privateKey: Buffer.from(wallet.privateKey.slice(2), "hex"), + messages, + }); + + for (let i = 0; i < messages.length; i++) { + const recovered = recoverCompositeTypedDataSig({ + signature: result.signature, + merkleRoot: result.merkleRoot, + proof: result.proofs[i], + message: messages[i], + }); + if ( + recovered == null || + recovered.toLowerCase() != wallet.address.toLowerCase() + ) { + throw new Error("Recovered address does not match"); + } + } + + console.log("All messages recovered ✅"); + + const nonRecovered = recoverCompositeTypedDataSig({ + signature: result.signature, + merkleRoot: result.merkleRoot, + proof: result.proofs[0], + message: nonMessage, + }); + + if (nonRecovered != null) { + throw new Error("Non-message recovered ❌"); + } + + console.log("Non-message not recovered ✅"); + + const singleMessage = await eth_signTypedData_v5({ + privateKey: Buffer.from(wallet.privateKey.slice(2), "hex"), + messages: messages[0], + }); + + const singleMessageSig = sigUtil.signTypedData_v4( + Buffer.from(wallet.privateKey.slice(2), "hex"), + { + data: messages[0], + } + ); + + if (singleMessage.signature != singleMessageSig) { + throw new Error("Single message signature does not match"); + } + + console.log("Single message signature matches ✅"); +} + +main(); diff --git a/assets/erc-7920/src/merkle.ts b/assets/erc-7920/src/merkle.ts new file mode 100644 index 00000000000..1d27e214b6a --- /dev/null +++ b/assets/erc-7920/src/merkle.ts @@ -0,0 +1,53 @@ +import { keccak256 } from "@ethersproject/keccak256"; + +export function _keccak256(data: Buffer): Buffer { + return Buffer.from(keccak256(data).slice(2), "hex"); +} + +export class MerkleTree { + private readonly levels: Buffer[][]; + + constructor(_messages: readonly Buffer[]) { + const messages = [..._messages]; + let k = Math.ceil(Math.log2(messages.length)); + for (let i = messages.length; i < 1 << k; i++) { + messages.push(Buffer.alloc(messages[0].length)); + } + + let currentLevel = messages; + this.levels = [currentLevel]; + while (currentLevel.length > 1) { + const nextLevel = []; + for (let i = 0; i < currentLevel.length; i += 2) { + const pair = + currentLevel[i].compare(currentLevel[i + 1]) < 0 + ? [currentLevel[i], currentLevel[i + 1]] + : [currentLevel[i + 1], currentLevel[i]]; + nextLevel.push(_keccak256(Buffer.concat(pair))); + } + currentLevel = nextLevel; + this.levels.push(nextLevel); + } + } + + getProof(message: Buffer): readonly Buffer[] { + // ceil(8/2)-1 + let index = this.levels[0].findIndex((m) => m.compare(message) === 0); + if (index === -1) { + throw new Error("Message not found"); + } + let levelIndex = 0; + let level = this.levels[0]; + let proof: Buffer[] = []; + while (level.length > 1) { + proof.push(level[index ^ 1]); + index = Math.ceil((index + 1) / 2) - 1; + level = this.levels[++levelIndex]; + } + return proof as readonly Buffer[]; + } + + getRoot(): Buffer { + return this.levels[this.levels.length - 1][0]; + } +} diff --git a/assets/erc-7920/test/merkle.test.ts b/assets/erc-7920/test/merkle.test.ts new file mode 100644 index 00000000000..266b0973593 --- /dev/null +++ b/assets/erc-7920/test/merkle.test.ts @@ -0,0 +1,75 @@ +import { expect } from "chai"; +import { MerkleTree, _keccak256 } from "../src/merkle"; +import { randomBytes } from "crypto"; + +describe("MerkleTree", function () { + function createRandomMessages(count: number, size: number = 32): Buffer[] { + return Array.from({ length: count }, () => randomBytes(size)); + } + + describe("Proof Size Tests", function () { + it("should have empty proof for single element tree", function () { + const messages = createRandomMessages(1); + const tree = new MerkleTree(messages); + const proof = tree.getProof(messages[0]); + + expect(proof).to.be.an("array").that.is.empty; + }); + + it("should have ceil(log2(n)) proof elements for even message count", function () { + const testCases = [2, 4, 6, 8, 10]; + + for (const count of testCases) { + const messages = createRandomMessages(count); + const tree = new MerkleTree(messages); + + for (const message of messages) { + const proof = tree.getProof(message); + const expectedProofSize = Math.ceil(Math.log2(count)); + + expect(proof).to.have.lengthOf( + expectedProofSize, + `Proof length for ${count} messages should be ${expectedProofSize}` + ); + } + } + }); + + it("should have ceil(log2(n)) proof elements for odd message count", function () { + const testCases = [3, 5, 7, 9, 15]; + + for (const count of testCases) { + const messages = createRandomMessages(count); + const tree = new MerkleTree(messages); + + for (const message of messages) { + const proof = tree.getProof(message); + const expectedProofSize = Math.ceil(Math.log2(count)); + + expect(proof).to.have.lengthOf( + expectedProofSize, + `Proof length for ${count} messages should be ${expectedProofSize}` + ); + } + } + }); + + it("should have consistent proof size for all elements in the same tree", function () { + for (const count of [3, 5, 8, 10]) { + const messages = createRandomMessages(count); + const tree = new MerkleTree(messages); + + const proofLengths = new Set(); + for (const message of messages) { + const proof = tree.getProof(message); + proofLengths.add(proof.length); + } + + expect(proofLengths.size).to.equal( + 1, + `All proofs for ${count} messages should have the same length` + ); + } + }); + }); +}); diff --git a/assets/erc-7920/test/solidity.test.ts b/assets/erc-7920/test/solidity.test.ts new file mode 100644 index 00000000000..506a3c10c16 --- /dev/null +++ b/assets/erc-7920/test/solidity.test.ts @@ -0,0 +1,170 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { ExampleVerifier } from "../typechain-types"; +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { MerkleTree } from "../src/merkle"; + +describe("SolidityTests", function () { + let verifier: ExampleVerifier; + let signer: SignerWithAddress; + let otherAccount: SignerWithAddress; + let signerWallet: ethers.Wallet; + let otherWallet: ethers.Wallet; + + const orders = [ + { + orderId: ethers.id("order1"), + user: "0x1234567890123456789012345678901234567890", + }, + { + orderId: ethers.id("order2"), + user: "0x2345678901234567890123456789012345678901", + }, + { + orderId: ethers.id("order3"), + user: "0x3456789012345678901234567890123456789012", + }, + ]; + + async function getOrderHash(order: { + orderId: string; + user: string; + }): Promise { + const contractHash = await verifier.debugGenerateMessageHash( + order.orderId, + order.user + ); + return Buffer.from(contractHash.slice(2), "hex"); + } + + beforeEach(async function () { + const VerifierFactory = await ethers.getContractFactory("ExampleVerifier"); + verifier = await VerifierFactory.deploy(); + await verifier.waitForDeployment(); + + [signer, otherAccount] = await ethers.getSigners(); + + signerWallet = ethers.Wallet.createRandom().connect(ethers.provider); + otherWallet = ethers.Wallet.createRandom().connect(ethers.provider); + + orders[0].user = signerWallet.address; + orders[1].user = signerWallet.address; + orders[2].user = signerWallet.address; + }); + + it("should successfully place an order with a composite signature", async function () { + const orderHashes = await Promise.all(orders.map(getOrderHash)); + const tree = new MerkleTree(orderHashes); + const merkleRoot = `0x${tree.getRoot().toString("hex")}`; + const signature = signerWallet.signingKey.sign(tree.getRoot()).serialized; + + const proof = tree + .getProof(orderHashes[0]) + .map((p) => `0x${p.toString("hex")}`); + + await expect( + verifier.placeOrder( + orders[0].orderId, + signerWallet.address, + signature, + merkleRoot, + proof + ) + ).to.not.be.reverted; + }); + + it("should reject an order with invalid proof", async function () { + const orderHashes = await Promise.all(orders.map(getOrderHash)); + const tree = new MerkleTree(orderHashes); + const merkleRoot = `0x${tree.getRoot().toString("hex")}`; + const signature = signerWallet.signingKey.sign(tree.getRoot()).serialized; + + // Use a valid proof but for a different order ID (intentionally wrong) + const invalidOrderId = ethers.id("invalid-order"); + const proof = tree + .getProof(orderHashes[0]) + .map((p) => `0x${p.toString("hex")}`); + + await expect( + verifier.placeOrder( + invalidOrderId, + signerWallet.address, + signature, + merkleRoot, + proof + ) + ).to.be.revertedWithCustomError(verifier, "NotInTree"); + }); + + it("should directly sign and verify a single-element merkle tree", async function () { + const order = orders[0]; + const messageHash = await verifier.debugGenerateMessageHash( + order.orderId, + order.user + ); + const leaf = Buffer.from(messageHash.slice(2), "hex"); + const tree = new MerkleTree([leaf]); + const merkleRoot = `0x${tree.getRoot().toString("hex")}`; + const signature = signerWallet.signingKey.sign(tree.getRoot()).serialized; + + await expect( + verifier.placeOrder( + order.orderId, + signerWallet.address, + signature, + merkleRoot, + // Proof for a single element tree is empty... + [] as string[] + ) + ).to.not.be.reverted; + }); + + it("should verify all orders with the same signature", async function () { + const orderHashes = await Promise.all(orders.map(getOrderHash)); + const tree = new MerkleTree(orderHashes); + const merkleRoot = `0x${tree.getRoot().toString("hex")}`; + const signature = signerWallet.signingKey.sign(tree.getRoot()).serialized; + + // Verify each order works with the same signature + for (let i = 0; i < orders.length; i++) { + const proof = tree + .getProof(orderHashes[i]) + .map((p) => `0x${p.toString("hex")}`); + + await expect( + verifier.placeOrder( + orders[i].orderId, + signerWallet.address, + signature, + merkleRoot, + proof + ) + ).to.not.be.reverted; + } + }); + + it("should reject a signature from a different signer", async function () { + const orderHashes = await Promise.all(orders.map(getOrderHash)); + const tree = new MerkleTree(orderHashes); + const merkleRoot = `0x${tree.getRoot().toString("hex")}`; + + // Sign with the other wallet (different than the one in the order.user field) + const wrongSignature = otherWallet.signingKey.sign( + tree.getRoot() + ).serialized; + + const proof = tree + .getProof(orderHashes[0]) + .map((p) => `0x${p.toString("hex")}`); + + await expect( + verifier.placeOrder( + orders[0].orderId, + signerWallet.address, // The actual order owner + wrongSignature, + merkleRoot, + proof + ) + ).to.be.revertedWithCustomError(verifier, "Unauthorized"); + }); +});