# NFT验证签名

By [0xY](https://paragraph.com/@0xy-2) · 2023-10-29

---

NFT签名验证 在区块链中，区块链地址都分为私钥和地址。私钥可以生成地址，反过来，地址不能生成私钥。如何证明一个地址是属于某个人的，我们可以给他一段消息，让他用私钥对这段消息进行签名。拿到签名，再结合这个地址可以证明，这段信息是否是由这个地址对应的私钥签名，从而也就证明地址的所有权。 步骤 1 项目方的数据库中保存白名单用户 2 前端将地址发送给后端比对是否是白名单用户 3 是白名单用户则使用管理员私钥对这段地址进行签名 4 后端返回签名后的数据，前端将签名后的数据发送给合约验证 5 合约验证通过则用户可以mint

Openzeppelin的ECDSA可以实现这个操作

    function recover(bytes32 hash, bytes memory signature) internal pure returns (address) {
        (address recovered, RecoverError error) = tryRecover(hash, signature);
        _throwError(error);
        return recovered;
    }
    

方法接收两个参数： ▪ hash➡️原始带签名信息的哈希值 ▪ signature➡️签名数据

返回值是对这段信息签名的地址，也就是我们在步骤3中所说的管理员地址。也就是说，我们需要将管理员的地址设置在合约中进行对比，注意管理员的地址一定不能作为参数传入，否则用任意一个地址做签名后再传入这个地址做验证，那结果一定是满足的。 这段代码调用的tryRecover中的逻辑涉及到数学原理 使用ECDSA来实现验证逻辑：

    pragma solidity 0.8.13;
    
    import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
    
    contract TestSig {
    
        // 使用 using 方法，就可以直接使用 bytes32 类型调用方法
        using ECDSA for bytes32;
    
        address public owner;
    
        constructor() {
            // 管理员地址（仅测试，不要对这个地址转账）
            owner = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266;
        }
    
        function verify(bytes memory signature) public view returns (bool) {
            // 验证签名者是否是管理员
            return recoverSigner(signature) == owner;
        }
    
        function recoverSigner(bytes memory signature) public view returns (address) {
            // 注意这里待哈希的内容需要与链下签名方法保持一致即可
            // 可以加盐或者其他数据来保持唯一性，防止重放攻击
            // 这里简单起见，仅对调用者的地址进行哈希签名
            bytes32 messageHash = keccak256(abi.encodePacked(msg.sender));
            // 调用 recover 验证签名地址
            address signer = messageHash.toEthSignedMessageHash().recover(signature);
            return signer;
        }
    }
    

链上验证逻辑实现后需要做一个链下信息签名。

    const ethers = require('ethers');
    const main = async () => {
        // 管理员地址与私钥（仅测试，不要对这个地址转账）
        const owner = '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266';
        const privateKey = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
        const signer = new ethers.Wallet(privateKey);
        console.log(signer.address)
    
        // 由于我们之前是使用了 msg.sender 进行签名
        // 因此这里需要改为合约的调用地址，其实就是msg.sender
        let message = '0xxxxx';
        // 计算信息的哈希值，第一个参数为类型，第二个为具体的值
        let messageHash = ethers.utils.solidityKeccak256(['address'], [message]);
        console.log("Message Hash: ", messageHash);
        // 由于 ethers 库的要求，需要先对哈希值数组化
        let messageBytes = ethers.utils.arrayify(messageHash);
        console.log("messageBytes: ", messageBytes);
        // 签名
        let signature = await signer.signMessage(messageBytes);
        console.log("Signature: ", signature);
    }
    const runMain = async () => {
        try {
            await main();
            process.exit(0);
        }
        catch (error) {
            console.log(error);
            process.exit(1);
        }
    };
    runMain();
    

生成签名数据，将签名数据传入合约的verify方法，就可以验证签名信息的准确性。 我们这里的签名信息仅仅是对合约发送者的签名，这可能会造成签名信息被重复利用从而造成重放攻击。因此我们一般在实际项目开发中都会加上一些其他的信息，比如合约本身的地址（这样签名就确认只能应用于这个合约），或者盐（随机数确保随机）。 EIP-712使用详情 利用签名技术我们可以实现一些功能例如白名单校验等。但是这种签名技术的应用场景比较简单，一般就是给一串字符串，或者一串哈希签名，如果我们想为更复杂的数据签名就无法实现了。 EIP-712出现是为了解决这个问题，利用EIP-712，我们可以对更大的数据集，例如对结构体进行签名。在Uniswap PancakeSwap等DEX在移除LP流动性的时候，我们需要先签名，然后再发送一笔交易移除流动性。正常情况下，其实我们先调用LP代币的授权方法，授权DEX合约可以转移LP，然后再去移除流动性。这种二合一的实现正是应用了EIP-712。他帮助我们只需要签名一次就可以将两部交易合并为一步交易，从而节省gas费用。

基本结构 EIP712Domain 顾名思义，是一个与域 相关的结构体，总共包含五个字段： ▪ name 合约或者协议名称 ▪ version 合约的版本 ▪ chainId 合约部署的链Id，一般使用block.chainid 即当前链Id ▪ verifyingContract 签名的合约地址，一般用address(this) ▪ salt 随机数盐，一般不常用

DOMAIN\_SEPARATOR EIP712Domain数据的哈希值，即：

    DOMAIN_SEPARATOR = keccak256 (
                abi.encode(
                        EIP712DOMAIN_TYPEHASH,
                        keccak256(name,合约名称),
                        keccak256(version,合约版本),
                        chainId,
                        verifyingContract
                )
    )
    

EIP712DOMAIN\_TYPEHASH

    bytes32 constant EIP712DOMAIN_TYPEHASH = keccak256(
                "EIP712Domain(string name,string version,uint256 chainId,address verifying)"
    );
    

签名对象 这里我们以签名Mail 对象为例 struct Mail{ address from; address to; string contents; }

签名对象类型哈希 bytes32 internal constant TYPE\_HASH = keccak256( "Mail(address from,address to,string contents)" );

注意对象名要首字母大写，结构体字段按照函数签名编写，这是规范，套用即可 如果结构体中还有其他结构体，例如： struct Transaction{ Person from; Person to; Asset tx; }

struct Asset{ address token; uint256 amount; }

struct Person{ address wallet; string name; }

需要写成（按照首字母排序，因此Asset 在Person 前面）： Transaction(Person from,Person to,Asset tx) Asset(address token,uint256 amount) Person(address wallet,string name)

签名对象值哈希 keccak256( abi.encode( TYPE\_HASH, mail.from, [mail.to](http://mail.to), keccak256(bytes(mail.contents)) ) );

其中第一个参数为TYPE\_HASH ，即签名对象类型的哈希。接下来依次是对象的各个字段，如果变长类型例如string ,bytes 则需要对其进行哈希。例如：这里的mail.contents是srting 类型，因此需要进行哈希。 代码 合约

    // SPDX-License-Identifier: SEE LICENSE IN LICENSE
    pragma solidity ^0.8.0;
    
    contract EIP712Mail {
        //Mail 是带签名结构体
        struct Mail {
            address from;
            address to;
            string contents;
        }
    
        struct EIP712Domain {
            string name;
            string version;
            uint256 chainId;
            address verifyingContract;
        }
        bytes32 public immutable DOMAIN_SEPARATOR;
    
        //固定不变的
        bytes32 public constant EIP712DOMAIN_TYPEHASH =
            keccak256(
                "EIP712Domain(string name,strinf version,uint256 chainId,address verifyingContract)"
            );
    
        //签名对象哈希
        bytes32 internal constant TYPE_HASH =
            keccak256("Mail(address from,address to,string contents)");
    
        constructor() {
            //Domain的HASH
            DOMAIN_SEPARATOR = keccak256(
                //计算DMIAN_SEPARATOR哈希
                //这里的name为EIP712Mail，即合约名称
                abi.encode(
                    EIP712DOMAIN_TYPEHASH,
                    keccak256("EIP712Mail"),
                    keccak256("1"),
                    block.chainid,
                    address(this)
                )
            );
        }
    
        //计算待签名的结构体的哈希
        function hashStruct(Mail memory mail) public pure returns (bytes32) {
            return
                keccak256(
                    abi.encode(
                        TYPE_HASH,
                        mail.from,
                        mail.to,
                        keccak256(bytes(mail.contents))
                    )
                );
        }
    
        function verify(
            Mail memory mail,
            address signer,
            uint8 v,
            bytes32 r,
            bytes32 s
        ) public view returns (bool) {
            // Note:we need to use `encodePacked` here instead of `encode`
            //固定格式套用即可
            bytes32 digest = keccak256(
                abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, hashStruct(mail))
            );
            return ecrecover(digest, v, r, s) == signer;
        }
    }
    

verify 函数接收三个参数，分别是待签名结构体，签名地址，v，r，s。其中v，r，s是构成签名的三部分，签名一共有65个字节，前32个字节是r，接下来32个字节是s，最后一个字节是v，ecrecover 是Solidity内置函数，可以用于验证签名，他会根据digest以及签名内容v，r，s来计算出签名人的地址。如果结果等于传入的签名地址。则说明验证签名正确。 使用JavaScript进行签名： const { ethers } = require('ethers') // 将合约部署在 hardhat node 本地链上 const provider = new ethers.JsonRpcProvider()

// 这里我们使用 hardhat node 自带的地址进行签名 const privateKey = `0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80` const wallet = new ethers.Wallet(privateKey, provider)

async function sign() {  // 获取 chainId  const { chainId } = await provider.getNetwork()

// 构造 domain 结构体  // 最后一个地址字段，由于我们在合约中使用了 address(this)  // 因此需要在部署合约之后，将合约地址粘贴到这里  const domain = {    name: 'EIP712Mail',    version: '1',    chainId: 4,    verifyingContract: '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0', }  // The named list of all type definitions  // 构造签名结构体类型对象  const types = {    Mail: \[     { name: 'from', type: 'address' },     { name: 'to', type: 'address' },     { name: 'contents', type: 'string' },   \], }  // The data to sign  // 自行构造一个结构体的值  const value = {    from: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266',    to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8',    contents: 'xyyme', }  const signature = await wallet.signTypedData(domain, types, value)

// 将签名分割成 v r s 的格式  let signParts = ethers.Signature.from(signature)  console.log('>>> Signature:', signParts)  // 打印签名本身  console.log(signature) }

sign()

我们将rsv签名，value值，以及签名地址传给verify 函数进行验证，结果为true，说明验证成功 应用 在Uniswap中运用了EIP-712，使得移除流动性的操作由两步变成一步，减少了Gas的使用。Dai合约中有一个Permit函数，用于第三方授权，同样也是应用了EIP-712标准。 在ERC20中，A可以调用approve来对B进行授权，而Dai合约中的Permit函数的目的就是，A提前在联系啊对授权对象进行签名，这样第三方就可以拿着A的签名去调用permit 来实现A的授权操作，从而使A在不发送交易的情况下就能够完成授权操作。 Dai核心代码 contract Dai is LibNote {    // ERC20 信息，name 和 version 用于 domain 签名    string  public constant name     = "Dai Stablecoin";    string  public constant symbol   = "DAI";    string  public constant version  = "1";    uint8   public constant decimals = 18;    uint256 public totalSupply;

mapping (address => uint)                      public balanceOf;    mapping (address => mapping (address => uint)) public allowance;    // nonces 用于避免重放攻击    mapping (address => uint)                      public nonces;

// --- EIP712 niceties ---    bytes32 public DOMAIN\_SEPARATOR;

// 计算签名结构体 Permit 的哈希    // bytes32 public constant PERMIT\_TYPEHASH = keccak256("Permit(address holder,address spender,uint256 nonce,uint256 expiry,bool allowed)");

bytes32 public constant PERMIT\_TYPEHASH = 0xea2aa0a1be11a07ed86d755c93467f4f82362b452371d1ba94d1715123511acb;

constructor(uint256 chainId\_) public {        wards\[msg.sender\] = 1;        // 计算 domain 哈希        DOMAIN\_SEPARATOR = keccak256(abi.encode(            keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),            keccak256(bytes(name)),            keccak256(bytes(version)),            chainId\_,            address(this)       ));   }

// 常规授权方法    function approve(address usr, uint wad)        external returns (bool) {        allowance\[msg.sender\]\[usr\] = wad;        emit Approval(msg.sender, usr, wad);        return true;   }

// --- Approve by signature ---    // 重点是这里的 permit 函数    function permit(address holder, address spender, uint256 nonce, uint256 expiry,                    bool allowed, uint8 v, bytes32 r, bytes32 s) external   {        bytes32 digest =            keccak256(abi.encodePacked(                "\\x19\\x01",                DOMAIN\_SEPARATOR,                keccak256(abi.encode(PERMIT\_TYPEHASH,                                     holder,                                     spender,                                     nonce,                                     expiry,                                     allowed))       ));

require(holder != address(0), "Dai/invalid-address-0");        //在这判断是否为持有者签名（验证签名）        require(holder == ecrecover(digest, v, r, s), "Dai/invalid-permit");        //判断是否过期        require(expiry == 0 || now <= expiry, "Dai/permit-expired");        // 用于防止重放攻击        require(nonce == nonces\[holder\]++, "Dai/invalid-nonce");        uint wad = allowed ? uint(-1) : 0;        //进行授权        allowance\[holder\]\[spender\] = wad;        emit Approval(holder, spender, wad);   } }

permit 函数的参数中，holder地址需要在链下进行签名,spender 即被授权地址nonce用于防止重放攻击。

---

*Originally published on [0xY](https://paragraph.com/@0xy-2/nft)*
