# 以太坊上的几种签名: eth_sign, personal_sign, eth_signTypedData

By [rbtree](https://paragraph.com/@rbtree) · 2022-09-21

---

以太坊的签名算法是ECDSA-secp256k1，以下介绍的每一种签名都是基于该算法，只是用来签名的数据不同。

1 交易签名 eth\_sign
----------------

以太坊上，签名之前的交易结构如下。

    let transaction = {
            to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8',
            value: ethers.utils.parseEther('1'),
            data: '0xE0A293E08F72454CEd99E1769c3ebd21fD2C20a1',
            gasLimit: '22000',
            maxFeePerGas: ethers.utils.parseUnits('20', 'gwei'),
            maxPriorityFeePerGas: ethers.utils.parseUnits('5', 'gwei'),
            nonce: 1,
            type: 2,
            chainId: chainId, // 31337
    }
    

各项目的含义不再介绍，有兴趣可以查阅[https://ethereum.org/en/developers/docs/transactions/](https://ethereum.org/en/developers/docs/transactions/)

我们可以看到这里有to地址，但是没有from地址。from地址并不必要写出来，因为在私钥签名之后，根据签名和用来签名的数据就可以计算出私钥对应的地址（from地址）。

在ECDSA签名之前，我们需要先把上面的交易数据序列化。以太坊采用的序列化方式是RLP

[https://ethereum.org/en/developers/docs/data-structures-and-encoding/rlp/](https://ethereum.org/en/developers/docs/data-structures-and-encoding/rlp/)

rlp(\[chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data\])

ether.js有专门的函数完成这个功能

    let serializedTransaction = await ethers.utils.serializeTransaction(transaction)
    

对于上面的例子，我们序列化之后得到：

0x02f847827a690185012a05f2008504a817c8008255f09470997970c51812dc3a010c7d01b50e0d17dc79c8881bc16d674ec8000094e0a293e08f72454ced99e1769c3ebd21fd2c20a1c0

然后需要对serializedTransaction进行keccak256哈希，然后就可以用私钥做签名了。

本例中用来签名的账户为0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

privateKey=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

    let signingKey = new ethers.utils.SigningKey(privateKey)
    let hash = ethers.utils.keccak256(serializedTransaction)
    let signature = await signingKey.signDigest(hash)
    let { v, r, s } = signature
    

我们可以得到签名的signature，signature有3个部分: v(1字节)，r(32字节)，s(32字节)

上面的例子中，我们可以得到

hash=0xdd54f25d0bbeae343a1dec3acce8dc5b3a0886bdb9c5e667f65d5bbbe11bc093

r: '0x0d254c2662f6b1db272520fdb6b64d2402849058e89deab6c64d3b6cf2ebea4c',

s: '0x73460b644f90d5ccc59b3737f6e514b4ff91053756846bfbff16fa38d017a1b5',

v: 28

其实这个签名的过程我们也可以使用小狐狸钱包完成。我们需要把privateKey导入，为了得到一样的结果，我们需要切换网络到本地区块链。在网页中，F12打开console。输入如下命令：

    hash="0xdd54f25d0bbeae343a1dec3acce8dc5b3a0886bdb9c5e667f65d5bbbe11bc093"
    ethereum.request({method:"eth_sign", params: [ethereum.selectedAddress, hash]}).then(console.log)
    

我们看到小狐狸出现如下弹窗：

![](https://storage.googleapis.com/papyrus_images/4bbc9a20951d07fd4489ab6577232d4b721b485510bf5b2807105c94f131bcc5.png)

我们可以注意到红字的提醒。eth\_sign是一个非常危险的操作，因为它是对交易的签名。如果大家在交互网站的时候发现弹出这样的签名，如果不懂千万不要随意签名。因为不怀好意的攻击者可能会拿着任何一个交易让你签名，这个交易可以做任何事情，例如可以把你钱包里的所有资金转走。

小狐狸签名的结果如下：

![](https://storage.googleapis.com/papyrus_images/b4c1e9640d87761a9bdec5bffe02694156011a359570cac03a1c017286f50344.png)

和我们使用ether.js算出的signature是一样的。

我们把r，s，v和之前的交易信息放在一起，就得到了raw\_transaction.

rlp(\[chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, v, r, s\])

不过这里有一个trick，需要把v的值从28改成1（如果v是27则改成0）。v值的定义，在以太坊上修改过2次，第一次是The Dao事件之后为了防范重放攻击做出的修改，第二次是EIP1559，具体参见

[https://eips.ethereum.org/EIPS/eip-155](https://eips.ethereum.org/EIPS/eip-155)

[https://eips.ethereum.org/EIPS/eip-1559](https://eips.ethereum.org/EIPS/eip-1559)

于是我们得到

0x02f88a827a690185012a05f2008504a817c8008255f09470997970c51812dc3a010c7d01b50e0d17dc79c8881bc16d674ec8000094e0a293e08f72454ced99e1769c3ebd21fd2c20a1c001a00d254c2662f6b1db272520fdb6b64d2402849058e89deab6c64d3b6cf2ebea4ca073460b644f90d5ccc59b3737f6e514b4ff91053756846bfbff16fa38d017a1b5

上面这串数字就是我们在交易的时候，最终需要广播给全网矿工的东西。

如果你用ether.js的话，还有一个更简单的把交易进行签名和序列化的函数，应该会得到一样的结果。

    let wallet = new ethers.Wallet(privateKey)
    let signedTransaction = await wallet.signTransaction(transaction)
    

2 消息签名 personal\_sign
---------------------

在以太坊中，签名不仅仅只会发生在发送交易的时候。

有时候，某个网站可能需要确认我们是否是某个账号的主人，那么它会拿一条消息出来，让我们签名，以便它确认我们的身份。

也有的智能合约是支持元交易(meta transaction)，这样的设计可以帮助我们节省gas费。假设某个ERC20代币合约支持这样的交易，那么可能会有这样的操作。我打算给Bob付一笔钱，但是我不想支付gas费，那么我可以线下做个签名，让Bob拿着我的线下签名自己去发起交易领取。在智能合约的逻辑中，会通过ecrecover判断我签名的真实性，如果是真实有效的，Bob就能从我的账户里拿走钱财。

这样的设计虽然给我们带来了方便，但是也给我们带来了风险，如果在攻击者的诱导下，用我们的账号给支持这种功能的合约做了签名，那么我们的财产就不保了。opensea上发生的一些NFT零元购事件就是这样的原因[https://mp.weixin.qq.com/s/XF1Zo\_wWmrjRYsHykvR2Eg](https://mp.weixin.qq.com/s/XF1Zo_wWmrjRYsHykvR2Eg)

下面我们具体看一下personal\_sign，这个签名的标准在EIP-191里有描述

[https://eips.ethereum.org/EIPS/eip-191](https://eips.ethereum.org/EIPS/eip-191)

"\\x19Ethereum Signed Message:\\n" + len(message) + message

对上面的字符串进行keccak256，然后再用私钥签名即可。

我们可以看到有一个前缀“\\x19Ethereum Signed Message:\\n”，这就保证了这样消息不可能是第一节说的那种危险的交易签名，一定程度上提高了安全性。

### 2.1 原始消息签名

我们依然用第1节里的账号进行实验，这次我们签名的消息是“I will pay Bob 1 ETH.”

我们可以得到签名的结果为：

r: '0xd82e945511655405916227caa6f22e4c886676ee94a24d6919809abc4006e1e5',

s: '0x27dc36778b38306c47c2102aab5e294bc0bc9f73be4d0c97a4762f5b37338136',

v: 28

同样这个功能小狐狸钱包实现：

    message="I will pay Bob 1 ETH."
    ethereum.request({method:"personal_sign", params: [ethereum.selectedAddress, message]}).then(console.log)
    

![](https://storage.googleapis.com/papyrus_images/a2a213b8612d289a66ca14d982a6403757652265a2550c09f99b0706cdf06119.png)

![](https://storage.googleapis.com/papyrus_images/67f647f3002beb10e0cccb5c51b31b5274369bf26a1459fa0db8ca7b84e8a4ee.png)

实际上ether.js有一个更方便的函数：

    let message = "I will pay Bob 1 ETH.";
    let messageBytes = ethers.utils.toUtf8Bytes(message);
    let sig = await signer.signMessage(messageBytes);
    

### 2.2 消息hash签名

上面说的是对整个消息签名，不过有时候我们的消息会很长。我们在链上验证的时候，是需要把整个消息都传上去的，这就会有点不方便且会消耗更多gas。所有有些项目是对消息做了一次keccak256哈希，然后把hash作为消息再处理。因为hash过后固定为32字节，所以需要上传的消息也就固定为32字节。

我们仍然拿上面的message="I will pay Bob 1 ETH."作为例子，我们对这个消息进行keccak256哈希，得到0x22658f1dab0720e8bf599551cc6dd75230ed4efefc7f6694f0b389e0807a0e52

消息前缀的添加方式和之前一样，只是消息长度固定为32字节了。

"\\x19Ethereum Signed Message:\\n32" + message

    let messageHash = "0x22658f1dab0720e8bf599551cc6dd75230ed4efefc7f6694f0b389e0807a0e52";
    let privateKey = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
    let signingKey = new ethers.utils.SigningKey(privateKey);
    
    let prefix = "\x19Ethereum Signed Message:\n32";
    let hash = ethers.utils.keccak256(ethers.utils.solidityPack(
            ["string", "bytes32"],
            [prefix, messageHash]));
    let sig = await signingKey.signDigest(hash);
    

我们可以得到签名的结果为：

r: '0xedd6c4704b1ac676c8f13f96e54ae2dcb76d942f7bfaea178aaeaeff4033c2c0',

s: '0x2d9b6511fc1d9948fa8f5261ee290f507d25d4b2c0378cd316c1ccbc25d24bf7',

v: 27

同样我们也可以用小狐狸来完成签名：

    hash="0x22658f1dab0720e8bf599551cc6dd75230ed4efefc7f6694f0b389e0807a0e52"
    ethereum.request({method:"personal_sign", params: [ethereum.selectedAddress, hash]}).then(console.log)
    

![](https://storage.googleapis.com/papyrus_images/047394e4dd63423585c7b823116ccd4941ce4afacab6dd2626288e3e47a13ec6.png)

![](https://storage.googleapis.com/papyrus_images/ebc0490437f93b462a74cfb2f42fa76dcfa5f51f25bd725ba80f9c893fbf06a9.png)

ether.js签名函数也可以实现这个功能：

    let messageHash = "0x22658f1dab0720e8bf599551cc6dd75230ed4efefc7f6694f0b389e0807a0e52";
    let sig = await signer.signMessage(ethers.utils.arrayify(messageHash));
    

这种签名方式其实是更加危险的，因为hash后的数据已经不可读了，当钱包弹出这样一个签名的时候，我们无从判断到底在签名什么。

3 结构化数据签名 eth\_signTypedData（EIP-712）
-------------------------------------

第2节中介绍的message并没有固定的格式，每个项目可以自行签名的标准。但是这只适合简单的情形，例如验证账户拥有者，此时签名的消息内容如何是无关紧要的。但是如果是涉及代币、NFT资产的转移，就需要比较小心了，如果设计不好很容易被攻击者利用。

于是发展出了标准的结构化签名标准EIP-712

[https://eips.ethereum.org/EIPS/eip-712](https://eips.ethereum.org/EIPS/eip-712)

这种签名的消息格式为：

"\\x19\\x01" + domainSeparator + structHash

其中domainSeprator是域信息，有这样一些可选项目：域名称，版本号，链ID，合约地址，盐值。

structHash是消息的具体信息，是一个自定义的结构类型名称 + 一系列的自定义参数。

我们来看一下如何用代码实现，这个代码模拟了一个代币Permit。

    const [signer] = await ethers.getSigners(); // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
    const chainId = await signer.getChainId(); // 31337
    const nonce = 0;
    const name = "Gold";
    const version = "1";
    const token = "0x959922bE3CAee4b8Cd9a407cc3ac1C251C2007B1";
    const spender = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
    const value = 1000;
    const deadline = 100;
    
    const abi = ethers.utils.defaultAbiCoder;
    
    const _PERMIT_TYPEHASH = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(
        "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"));
    
    const structHash = ethers.utils.keccak256(abi.encode(
        ["bytes32", "address", "address", "uint256", "uint256", "uint256"],
        [_PERMIT_TYPEHASH, signer.address, spender, value, nonce, deadline]));
    
    const typeHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(
        "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"));
    const nameHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(name));
    const versionHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(version));
    const domainSeparator = ethers.utils.keccak256(abi.encode(
        ["bytes32", "bytes32", "bytes32", "uint256", "address"],
        [typeHash, nameHash, versionHash, chainId, token]
    ));
    
    const typedDataHash = ethers.utils.keccak256(ethers.utils.solidityPack(
        ["string", "bytes32", "bytes32"],
        ["\x19\x01", domainSeparator, structHash]));
    
    const privateKey = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
    const signingKey = new ethers.utils.SigningKey(privateKey); // without prefix "\x19Ethereum Signed Message:\n32"
    
    const signature = await signingKey.signDigest(typedDataHash);
    

最终结果：

r: '0x967cf9274943d2048f132ca12e491bd730d00a5ddae5fad3979d8fa535a17d05',

s: '0x3f931eb53f53d7fe38f25a08157c14585903304850d334fb79854cc88943f435',

v: 27

我们用小狐狸来做一下交叉验证，

    let domain = [
        {name: "name", type: "string" },
        {name: "version", type: "string"},
        {name: "chainId", type: "uint256"},
        {name: "verifyingContract", type: "address"}
    ]
     
    let domainData = {
        name: "Gold",
        version: "1",
        chainId: ethereum.chainId,
        verifyingContract: "0x959922bE3CAee4b8Cd9a407cc3ac1C251C2007B1"
    }
    
    let permit = [
        {"name": 'owner', "type": 'address'},
        {"name": 'spender', "type": 'address'},
        {"name": 'value', "type": 'uint256'},
        {"name": 'nonce', "type": 'uint256'},
        {"name": 'deadline', "type": 'uint256'},
    ]
     
    let message = {
        owner: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
        spender: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8',
        value: 1000,
        nonce: 0,
        deadline: 100
    }
     
    let eip712TypedData = {
        types: {
            EIP712Domain: domain,
            Permit: permit
        },
        domain: domainData,
        primaryType: "Permit",
        message: message
    }
    
    let data = JSON.stringify(eip712TypedData)
    
    ethereum.request({method:"eth_signTypedData_v4", params: [ethereum.selectedAddress, data]}).then(console.log)
    

![](https://storage.googleapis.com/papyrus_images/809ecf7ab1fe5f3ddeb80098d7681fc2a66eb2b357070623fd7508437356865d.png)

![](https://storage.googleapis.com/papyrus_images/67457bbd19f5249c9e54a462d333c27667d1e677af630f8cfe36c1b8f785b3f9.png)

对于这种EIP-712格式的签名，ether.js当然本身就有相应的函数。上面写了那么多代码只是想要让大家直观看一下EIP-712的消息是如何组织的。

    const [signer] = await ethers.getSigners(); // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
    const chainId = await signer.getChainId(); // 31337
    const nonce = 0;
    const name = "Gold";
    const version = "1";
    const token = "0x959922bE3CAee4b8Cd9a407cc3ac1C251C2007B1";
    const spender = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
    const value = 1000;
    const deadline = 100;
    
    let sig = ethers.utils.splitSignature(
          await signer._signTypedData(
            {
              name,
              version,
              chainId,
              verifyingContract: token
            },
            {
              Permit: [
                {
                  name: "owner",
                  type: "address",
                },
                {
                  name: "spender",
                  type: "address",
                },
                {
                  name: "value",
                  type: "uint256",
                },
                {
                  name: "nonce",
                  type: "uint256",
                },
                {
                  name: "deadline",
                  type: "uint256",
                },
              ],
            },
            {
              owner: signer.address,
              spender,
              value,
              nonce,
              deadline,
            }
          )
        )
    

目前大部分项目的签名都是这种EIP-712格式的签名，它的优点就是最大程度地做到了“所见即所签”，如果对代币和NFT比较熟悉的朋友一眼就能看出这个签名是在做什么样的授权。

不过这个签名本身并不能保证安全性，还是需要用户对这些消息内容有一定程度的了解。例如攻击者故意让你做一个NFT合约的签名，依然可能会让很多小白用户在不知不觉中丢失资产。

---

*Originally published on [rbtree](https://paragraph.com/@rbtree/eth-sign-personal-sign-eth-signtypeddata)*
