From what I have tested, the signature signing and recovering process works perfect if there's no bytes params in the Permit typehash:

// SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.26; import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/cryptography/EIP712.sol"; import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/cryptography/ECDSA.sol"; /** * @title Ballot * @dev Implements voting process along with vote delegation */ contract Ballot is EIP712 { struct Order { uint256 organizationId; uint256 relatedId; AssetKey assetForSale; uint256 assetForSaleAmount; AssetKey assetToReceive; uint256 assetToReceiveAmount; uint256 expirationTimeStamp; bytes params; } enum AssetType { Native, Token, NFT, MultiNFT } bytes32 private constant TYPE_HASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); struct AssetKey { address assetAddress; uint256 id; AssetType assetType; } constructor() EIP712("AssetManagerBase", "1") { } function createOrderBySig( Order memory order, uint8 v, bytes32 r, bytes32 s, uint256 deadline ) external returns (address signer) { bytes32 digest = _hashTypedDataV4(keccak256(abi.encode( keccak256("Permit(uint256 organizationId,uint256 relatedId,address assetForSaleAddress,uint256 assetForSaleId,uint8 assetForSaleType,uint256 assetForSaleAmount,address assetToReceiveAddress,uint256 assetToReceiveId,uint8 assetToReceiveType,uint256 assetToReceiveAmount,uint256 expirationTimeStamp,uint256 deadline)"), order.organizationId, order.relatedId, order.assetForSale.assetAddress, order.assetForSale.id, order.assetForSale.assetType, order.assetForSaleAmount, order.assetToReceive.assetAddress, order.assetToReceive.id, order.assetToReceive.assetType, order.assetToReceiveAmount, order.expirationTimeStamp, deadline ))); signer = ECDSA.recover(digest, v, r, s); } function _hashTypedDataV4(bytes32 structHash) internal view override returns (bytes32) { bytes32 _hashedName = keccak256(bytes("AssetManagerBase")); bytes32 _hashedVersion = keccak256(bytes("1")); return MessageHashUtils.toTypedDataHash(keccak256(abi.encode(TYPE_HASH, _hashedName, _hashedVersion, 80084, 0x1aee6917D10F73C422bd587748230E40DFcD86cc)), structHash); } } contract Test { Ballot public ballot = new Ballot(); // ARGUMENTS: // 0x9f885c885fd203e6c9c4f0a87291b404e8844f687994d6400a798e1a3acf5efe, 0x2dbc271048207efbac91ae73cb91e5733c091488d7c70f5a73a9e3a4e1fd9423, 27 // expected return address: 0x8BaD7472acCe7b223Ef085028FBA29357F700501 <-- my test Metamask wallet's public key function test_Signature(bytes32 r, bytes32 s, uint8 v) external returns (address signer, Ballot.Order memory orderLocal) { Ballot.AssetKey memory assetForSale = Ballot.AssetKey({ assetAddress: 0x0000000000000000000000000000000000000000, id: 0, assetType: Ballot.AssetType.Native }); Ballot.AssetKey memory assetToReceive = Ballot.AssetKey({ assetAddress: 0x7526b73CADAaB31bA3FFEc305592f2f4713a8dB3, id: 0, assetType: Ballot.AssetType.Token }); orderLocal = Ballot.Order({ organizationId: 2, relatedId: 2, assetForSale: assetForSale, assetForSaleAmount: 1000000, assetToReceive: assetToReceive, assetToReceiveAmount: 99699890170, expirationTimeStamp: 1722804607, params: new bytes(0) }); uint256 deadline = 1722805807; signer = ballot.createOrderBySig(orderLocal, v, r, s, deadline); } }
import "./styles.css"; import { ethers } from "ethers"; const orderStruct = { organizationId: 2, relatedId: 2, assetForSaleAddress: "0x0000000000000000000000000000000000000000", assetForSaleId: 0, assetForSaleType: 0, assetForSaleAmount: 1000000, assetToReceiveAddress: "0x7526b73CADAaB31bA3FFEc305592f2f4713a8dB3", assetToReceiveId: 0, assetToReceiveType: 1, assetToReceiveAmount: 99699890170, expirationTimeStamp: 1722804607, // params: "0x0000000000000000000000000000000000000000000000000000000000000000", deadline: 1722805807, }; const typedData = { types: { EIP712Domain: [ { name: "name", type: "string" }, { name: "version", type: "string" }, { name: "chainId", type: "uint256" }, { name: "verifyingContract", type: "address" }, ], Permit: [ { name: "organizationId", type: "uint256" }, { name: "relatedId", type: "uint256" }, { name: "assetForSaleAddress", type: "address" }, { name: "assetForSaleId", type: "uint256" }, { name: "assetForSaleType", type: "uint8" }, { name: "assetForSaleAmount", type: "uint256" }, { name: "assetToReceiveAddress", type: "address" }, { name: "assetToReceiveId", type: "uint256" }, { name: "assetToReceiveType", type: "uint8" }, { name: "assetToReceiveAmount", type: "uint256" }, { name: "expirationTimeStamp", type: "uint256" }, // { name: "params", type: "bytes" }, notice: commented out! { name: "deadline", type: "uint256" }, ], }, primaryType: "Permit", domain: { name: "AssetManagerBase", version: "1", chainId: 80084, verifyingContract: "0x1aee6917D10F73C422bd587748230E40DFcD86cc", }, message: orderStruct, }; const main = async () => { await window.ethereum.enable(); const provider = new ethers.providers.Web3Provider(window.ethereum); const signer = provider.getSigner(); const myAccount = await signer.getAddress(); console.log(myAccount); const signatureOldButStillWorking = await signer.provider.send( "eth_signTypedData_v4", [myAccount, typedData] ); // === signature below const signature = await signer._signTypedData( typedData.domain, { Permit: typedData.types.Permit }, typedData.message ); console.log(signature); document.getElementById("app").innerHTML = `Signature: ${signature}`; const result = ethers.utils.splitSignature(signature); console.log(result); const joinSig = ethers.utils.joinSignature({ r: result.r, v: result.v, s: result.s, }); console.log({ joinSig }); console.log(Object.values(orderStruct)); const verifyMessage = async () => { try { const signerAddr = await ethers.utils.verifyTypedData( typedData.domain, { Permit: typedData.types.Permit }, typedData.message, signature ); if (signerAddr !== myAccount) { return false; } return true; } catch (err) { console.log(err); return false; } }; const isSigCorrect = await verifyMessage(); console.log({ isSigCorrect }); }; main();
CodeSandbox v1
As you can see from the code snippet and the screenshots, the process works flawlessly: 
However, when bytes params is added again, the decoded signer is always messed up and is incorrect.
Given that my Solidity mock for Remix is the following:
// SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.26; import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/cryptography/EIP712.sol"; import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/cryptography/ECDSA.sol"; /** * @title Ballot * @dev Implements voting process along with vote delegation */ contract Ballot is EIP712 { struct Order { uint256 organizationId; uint256 relatedId; AssetKey assetForSale; uint256 assetForSaleAmount; AssetKey assetToReceive; uint256 assetToReceiveAmount; uint256 expirationTimeStamp; bytes params; } enum AssetType { Native, Token, NFT, MultiNFT } bytes32 private constant TYPE_HASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); struct AssetKey { address assetAddress; uint256 id; AssetType assetType; } constructor() EIP712("AssetManagerBase", "1") { } function createOrderBySig( Order memory order, uint8 v, bytes32 r, bytes32 s, uint256 deadline ) external returns (address signer) { bytes32 digest = _hashTypedDataV4(keccak256(abi.encode( keccak256("Permit(uint256 organizationId,uint256 relatedId,address assetForSaleAddress,uint256 assetForSaleId,uint8 assetForSaleType,uint256 assetForSaleAmount,address assetToReceiveAddress,uint256 assetToReceiveId,uint8 assetToReceiveType,uint256 assetToReceiveAmount,uint256 expirationTimeStamp,bytes params,uint256 deadline)"), order.organizationId, order.relatedId, order.assetForSale.assetAddress, order.assetForSale.id, order.assetForSale.assetType, order.assetForSaleAmount, order.assetToReceive.assetAddress, order.assetToReceive.id, order.assetToReceive.assetType, order.assetToReceiveAmount, order.expirationTimeStamp, keccak256(order.params), deadline ))); signer = ECDSA.recover(digest, v, r, s); } function _hashTypedDataV4(bytes32 structHash) internal view override returns (bytes32) { bytes32 _hashedName = keccak256(bytes("AssetManagerBase")); bytes32 _hashedVersion = keccak256(bytes("1")); return MessageHashUtils.toTypedDataHash(keccak256(abi.encode(TYPE_HASH, _hashedName, _hashedVersion, 80084, 0x1aee6917D10F73C422bd587748230E40DFcD86cc)), structHash); } } contract Test { Ballot public ballot = new Ballot(); // ARGUMENTS: // 0xe115c0e7dc4935fde46f8511632c8dba2f8389c80dbaf0be13a5f13be1d636a4, 0x083e1ebf2c553e87587e0253747528c4f0aada6a630af6b09f542d7534d9efca, 28 // expected return address: 0x8BaD7472acCe7b223Ef085028FBA29357F700501 function test_Signature(bytes32 r, bytes32 s, uint8 v) external returns (address signer, Ballot.Order memory orderLocal) { Ballot.AssetKey memory assetForSale = Ballot.AssetKey({ assetAddress: 0x0000000000000000000000000000000000000000, id: 0, assetType: Ballot.AssetType.Native }); Ballot.AssetKey memory assetToReceive = Ballot.AssetKey({ assetAddress: 0x7526b73CADAaB31bA3FFEc305592f2f4713a8dB3, id: 0, assetType: Ballot.AssetType.Token }); bytes memory emptyBytes; orderLocal = Ballot.Order({ organizationId: 2, relatedId: 2, assetForSale: assetForSale, assetForSaleAmount: 1000000, assetToReceive: assetToReceive, assetToReceiveAmount: 99699890170, expirationTimeStamp: 1722804607, params: emptyBytes }); uint256 deadline = 1722805807; signer = ballot.createOrderBySig(orderLocal, v, r, s, deadline); } }
My Javascript code to make the process work on both the Javascript and Solidity sides has been tweaked with regards to how bytes are passed as a part of the message (orderStruct variable). While you pass the params as bytes32:
"params":"0x0000000000000000000000000000000000000000000000000000000000000000",
You should actually pass bytes as params: new Uint32Array(0), in the ethers.js and Javascript code, here's a full example:
import "./styles.css"; import { ethers } from "ethers"; import { randomBytes } from "ethers/lib/utils"; const orderStruct = { organizationId: 2, relatedId: 2, assetForSaleAddress: "0x0000000000000000000000000000000000000000", assetForSaleId: 0, assetForSaleType: 0, assetForSaleAmount: 1000000, assetToReceiveAddress: "0x7526b73CADAaB31bA3FFEc305592f2f4713a8dB3", assetToReceiveId: 0, assetToReceiveType: 1, assetToReceiveAmount: 99699890170, expirationTimeStamp: 1722804607, params: new Uint32Array(0), // empty bytes deadline: 1722805807, }; const typedData = { types: { EIP712Domain: [ { name: "name", type: "string" }, { name: "version", type: "string" }, { name: "chainId", type: "uint256" }, { name: "verifyingContract", type: "address" }, ], Permit: [ { name: "organizationId", type: "uint256" }, { name: "relatedId", type: "uint256" }, { name: "assetForSaleAddress", type: "address" }, { name: "assetForSaleId", type: "uint256" }, { name: "assetForSaleType", type: "uint8" }, { name: "assetForSaleAmount", type: "uint256" }, { name: "assetToReceiveAddress", type: "address" }, { name: "assetToReceiveId", type: "uint256" }, { name: "assetToReceiveType", type: "uint8" }, { name: "assetToReceiveAmount", type: "uint256" }, { name: "expirationTimeStamp", type: "uint256" }, { name: "params", type: "bytes" }, { name: "deadline", type: "uint256" }, ], }, primaryType: "Permit", domain: { name: "AssetManagerBase", version: "1", chainId: 80084, verifyingContract: "0x1aee6917D10F73C422bd587748230E40DFcD86cc", }, message: orderStruct, }; const main = async () => { await window.ethereum.enable(); const provider = new ethers.providers.Web3Provider(window.ethereum); const signer = provider.getSigner(); const myAccount = await signer.getAddress(); console.log(myAccount); try { const signatureOldButStillWorking = await signer.provider.send( "eth_signTypedData_v4", [myAccount, typedData] ); // THIS IS OUTDATED AND WILL NOT WORK FOR bytes, it'll only work without `bytes` } catch (err) { console.log({ err }); } const signature = await signer._signTypedData( typedData.domain, { Permit: typedData.types.Permit }, typedData.message ); console.log(signature); document.getElementById("app").innerHTML = `Signature: ${signature}`; const result = ethers.utils.splitSignature(signature); console.log(result); const joinSig = ethers.utils.joinSignature({ r: result.r, v: result.v, s: result.s, }); console.log({ joinSig }); console.log(Object.values(orderStruct)); const verifyMessage = async () => { try { const signerAddr = await ethers.utils.verifyTypedData( typedData.domain, { Permit: typedData.types.Permit }, typedData.message, signature ); if (signerAddr !== myAccount) { return false; } return true; } catch (err) { console.log(err); return false; } }; const isSigCorrect = await verifyMessage(); console.log({ isSigCorrect }); }; main();
That's how empty bytes in the EIP712 signature will be displayed in your Metamask prompt window: 
Proof: 
Orderstruct in Solidity.deadlineargument that you pass in thecreateOrderBySigcall correct (i.e. how it was defined in the signature data)? @RitzyDevUk