I have a client that signs EIP-712 messages:
eip_712.js
(dep: ethers: 5.7.1
)
const { Wallet } = require("@ethersproject/wallet");
const { _TypedDataEncoder } = require("@ethersproject/hash");
console.log("EIP-712 JS");
// ! this is a test private key, DO NOT USE IT
const privKey = '0x0123456789012345678901234567890123456789012345678901234567890123';
const signer = new Wallet(privKey);
console.log("Signer is: ", signer.address);
// ! You need to fix your ethers version because _signTypedData is experimental
// ! feature.
// * https://docs.ethers.io/v5/api/signer/#Signer-signTypedData
const domain = {
chainId: 0,
name: '',
verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC',
version: ''
};
// The named list of all type definitions
const types = {
Player: [
{ name: 'wallet', type: 'address' },
{ name: 'email', type: 'string' }
],
Message: [
{ name: 'player', type: 'Player' },
]
};
// The data to sign
const value = {
player: {
wallet: "0x14791697260E4c9A71f18484C9f997B308e59325",
email: "[email protected]",
}
};
console.log(
"Signing this digest: ", _TypedDataEncoder.hash(domain, types, value)
);
signer._signTypedData(
domain,
types,
value
).then(signature => {
console.log("Signature is: ", signature);
}).catch(e => {
console.log("Something went wrong", e);
});
And now I am trying to verify the from account in python:
eip_712.py
(deps: eip712 = "0.1.4"
, eth-account = "0.7.0"
)
#!/usr/bin/env python
from eip712.messages import EIP712Message, EIP712Type
from eth_account import Account
print("EIP-712 PY")
class Player(EIP712Type):
wallet: "address"
email: "string"
class Message(EIP712Message):
_chainId_: "uint256" = 0
_name_: "string" = ''
_verifyingContract_: "address" = '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC'
_version_: "string" = ''
player: Player
signature = bytes.fromhex('f65e247fc142c6fa98febae4dba127be1694e83a5fce938c4389945594a5acd454771cdf810b90f2f83d60c69f8d576d5ff8dab957f57b2a247c86baffae6d011c')
message = Message(player=Player(
wallet='0x14791697260E4c9A71f18484C9f997B308e59325',
email='[email protected]'
))
# this is the digest that the user signs on the client side
print('Digest that was signed: ', message.body.hex())
from_account = Account.recover_message(message.signable_message, signature=signature)
print('Account that signed the message: ', from_account)
The outputs for the above two are:
EIP-712 JS
Signer is: 0x14791697260E4c9A71f18484C9f997B308e59325
Signing this digest: 0x044b3a930b5d539e1ec6f5ab7de69a1c6c0f103a11c6377adbd81e92763df9be
Signature is: 0xf65e247fc142c6fa98febae4dba127be1694e83a5fce938c4389945594a5acd454771cdf810b90f2f83d60c69f8d576d5ff8dab957f57b2a247c86baffae6d011c
EIP-712 PY
Digest that was signed: e5477989520456a98eb64ab80304b4e9b1858fe542121f4722a0ea1e12aed8c9
Account that signed the message: 0xE01Fc9697648F725409D75B08f5e7D98CddD13bD
Looks like the digest these two methods are signing is different?
EDIT:
The digest on the Python side isn't actually the digest that is used to recover the account. I went into eth_account
lib and printed the actual digest. This is message_hash
on line 450 in the account.py
file. The actual digest that is used is: 0xa7875cf3863821279f6b8d2c254e34caec0e7376cb37fda6387150b0ec3aaf96
. This implies that eth_account
is using a different digest to the one that ethers.js
produces. I shall hardcode the ethers.js
digest into eth_account
to verify that if indeed the digest were correct, whether it would accurately recover the address.
EDIT:
Indeed, if I use the digest from ethers.js
, then eth_account
is recovering the correct signer address. This implies that if we figure out why eth_account
is producing a different digest, then we are in business.
EDIT:
I have verified that not using eip712
library and just encoding the structured data directly, produces the exact same incorrect digest on the Python side. i.e. the below is also invalid:
signable_message = eth_account.messages.encode_structured_data(
{
"message": {
"player": {
"wallet": "0x14791697260E4c9A71f18484C9f997B308e59325",
"email": "[email protected]"
}
},
"types": {
"EIP712Domain": [
{"name": "chainId", "type": "uint256"},
{"name": "name", "type": "string"},
{"name": "verifyingContract", "type": "address"},
{"name": "version", "type": "string"}
],
"Player": [
{"name": "wallet", "type": "address"},
{"name": "email", "type": "string"}
],
"Message": [
{"name": "player", "type": "Player"}
]
},
"primaryType": "Message",
"domain": {
"chainId": 0,
"name": "",
"verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC",
"version": ""
}
}
)
EDIT: I am noticing discrepancy in encoding the header of the message. Ethers.js encodes domain into: 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400fc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a4700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cccccccccccccccccccccccccccccccccccccccc
, whilst eip712
(and eth_account
) encode it into: 0x03fdd899fb21de70ced90f32a1783b1f2014b8b662f8233df477aeefb0ff0e4a0000000000000000000000000000000000000000000000000000000000000000c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470000000000000000000000000ccccccccccccccccccccccccccccccccccccccccc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470
And the above appears to be a discrepancy in the order of encoding in conjunction with encoding the primary type differently.
EDIT: having noticed the discrepancy in encoding the header of the message, and noticing that only one field from the EIP712 domain is required, I managed to achieve the same encoding using a single value in the header. Namely, instead of using all the: chainId
, verifyingContract
, name
and version
, I am only using name
, which achieves parity in encoding. This, therefore, suggests that to fix the discrepancy between the two, some sorting must be applied. Either on the ether.js
side or python
side. Given ethers.js
already does field name sorting, and because it is more widely used on the client side, it makes sense to fix the sorting on the Python side.
EDIT: there appears to be no issue about encoding and having the body, they match. Therefore, I can retrieve the correct signer on the Python side.
EDIT: I have made a repo that reproduces all of the above, you can find it in this commit: https://github.com/nazariyv/eip712-discrepancy/commit/c7baa4630cbb5f1d01b5f123ea0727d527fb25c4