6

I'm trying to make a method which can take and verify order via signature:

I'm using OpenZeppelin EIP712 implementation and its instantiated like so

EIP712("AssetManagerBase", "1") 

This is the bulk of my logic:

function createOrderBySig( OrganizationAccountIdentityKey calldata accountIdentityKey, Order calldata order, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) external returns (uint256) { require(block.timestamp <= deadline, "Signature expired"); //Hardcode the permit string so we don't need to access storage 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, order.params, deadline ))); address signer = ECDSA.recover(digest, v, r, s); //... } 

I'm trying to call this using ethers from my frontend, I'm using type chain to assure the parameters passed is correct so the error should be in my signing logic:

 gatherPermitSignature: async function gatherPermitSignature() { //TODO: Why do we need permit validity buffer, the permit can only be used before the transaction deadline? const signatureDeadline = transactionDeadline.toNumber() + PERMIT_VALIDITY_BUFFER const domain = { name: 'AssetManagerBase', version: '1', chainId, verifyingContract: getAddress(ASSET_MANAGER_ADDRESS_MAP[chainId]), // use checksummed address } const message = { organizationId: accountIdentityKey.organizationId, relatedId: accountIdentityKey.relatedId, assetForSaleAddress: getSwapCurrencyAddressForOrder(order.assetForSale.currency), assetForSaleId: '0', // assetId for Tokens and Native will always be 0 assetForSaleType: '0', assetForSaleAmount: order.assetForSale.numerator.toString(), assetToReceiveAddress: getSwapCurrencyAddressForOrder(order.assetToReceive.currency), assetToReceiveId: '0', // assetId for Tokens and Native will always be 0 assetToReceiveType: '1', assetToReceiveAmount: order.assetToReceive.numerator.toString(), expirationTimeStamp: order.expirationTimeStamp.toString(), params: order.params, deadline: signatureDeadline.toString(), } const data = JSON.stringify({ 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' }, ], }, domain, primaryType: 'Permit', message, }) return provider .send('eth_signTypedData_v4', [getAddress(account), data]) .then(splitSignature) 

So not sure whats missings from what i can see.

  1. The domain is using the same parameters supplied to the EIP712, and the contract address

  2. The Type name is called Permit just like in the _hashTypedDataV4.

  3. All of the parameters are aligned from what i can see.

I unit tested this method in foundry so i know it should work. However I cannot figure out a way to make the signature work from the frontend. Does anyone see what the issue could be?

And here is the message provided when being signed:

Domain:{"chainId":80084,"name":"AssetManagerBase","verifyingContract":"0x1aee6917D10F73C422bd587748230E40DFcD86cc","version":"1"} Message { "assetForSaleAddress":"0x0000000000000000000000000000000000000000", "assetForSaleAmount":"1000000", "assetForSaleId":"0", "assetForSaleType":"0", "assetToReceiveAddress":"0x7526b73CADAaB31bA3FFEc305592f2f4713a8dB3", "assetToReceiveAmount":"99699890170", "assetToReceiveId":"0", "assetToReceiveType":"1", "deadline":"1722805807", "expirationTimeStamp":"1722804607", "organizationId":"2", "params":"0x0000000000000000000000000000000000000000000000000000000000000000", "relatedId":"2" } 

Edit: Providing more information about the structure and types:

struct Order { uint256 id; bytes32 accountId; uint256 organizationId; uint256 relatedId; AssetKey assetForSale; uint256 assetForSaleAmount; AssetKey assetToReceive; uint256 assetToReceiveAmount; uint256 expirationTimeStamp; FeeDetails feeDetails; bytes params; } enum AssetType { Native, Token, NFT, MultiNFT } struct AssetKey { address assetAddress; uint256 id; AssetType assetType; } 

Note: According to the typechain generation enums are represented as a uint8

5
  • please share the Order struct in Solidity. Commented Aug 7, 2024 at 9:22
  • 1
    @MilaA I've added the struct for clarity on the types. Not some of the fields are not used in the permit as there assigned from the contract. Commented Aug 7, 2024 at 14:44
  • Also, is the deadline argument that you pass in the createOrderBySig call correct (i.e. how it was defined in the signature data)? @RitzyDevUk Commented Aug 7, 2024 at 14:58
  • look at the openzeppelin tests github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.9.5/test/… Commented Aug 7, 2024 at 23:14
  • 1
    Hopefully my suggestion helps, please check it out if you're still looking for the solution :) Commented Aug 8, 2024 at 21:08

2 Answers 2

4
+100

My suggestions:

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

    result_proof

    // 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: permit-signature-eip712-doesnt-work-when-there-is-bytes-in-typehash-solidity-javascript-ethersjs-ethersjs6-help

    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: eip-712-ethers-js-metamask-signatures-empty-bytes-calldata-memory-storage-bug

    Proof: remix-eip712-signature-decoding-ecdsa-openzeppelin-v4-v5-v6-returns-wrong-address-how-to-fix-culprit

  2. It's also recommended to wrap the bytes in Solidity EIP712 signatures with keccak256, notice the change in the Solidity-side code.

Additionally, if this method doesn't work:

Apparently the culprit could be that you're using the Berachain Artio testnet, and most of its RPCs are configured incorrectly.

For instance, with the recommended (community-supported) https://artio.rpc.berachain.com/ RPC URL, the block.chainid during the EVM executions returns 1 and the real network id is 1 (you can verify it yourself with Foundry's cast: cast chain-id --rpc-url https://artio.rpc.berachain.com/, which returns 1).


That discrepancy could cause problems (read more here: https://ethereum.stackexchange.com/a/124537/120999).

The returned RPC node's chain id is 1.

The only RPC that works fine as far as I can tell is: https://api-internal.routescan.io/private/v2/network/testnet/evm/80084/rpc


My answer also shows how to recover and verify EIP712 ECDSA typed v4 signatures with ethers.js and also check the same recovery message and process, and typehashes with Solidity and OpenZeppelin's ECDSA.

Also you can see how to verify signatures for other contracts (recovering signatures for domain EIP712 contracts [verifyingAddress] other than the current contract address, as you can see).


Hope that helps you! :)


2
  • @johnny-5, please check out my answer too! :) Commented Aug 8, 2024 at 21:07
  • 1
    Since I can't confirm with the op, and this seems to be well tested I'll mark as resolved Commented Aug 9, 2024 at 18:23
2

it just an issue with eip712 compliance, meaning hashing the struct(s) correctly.

in your case its the bytes, they should be hashed first before passed to the final _hashTypedData, according to spec https://eips.ethereum.org/EIPS/eip-712#definition-of-typed-structured-data-%F0%9D%95%8A

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.