跳到主要内容

Ethers极简入门: 18. 数字签名脚本

我最近在重新学ethers.js,巩固一下细节,也写一个WTF Ethers极简入门,供小白们使用。

推特@0xAA_Science

WTF Academy社群: 官网 wtf.academy | WTF Solidity教程 | discord | 微信群申请

所有代码和教程开源在github: github.com/WTFAcademy/WTFEthers


这一讲,我们介绍一个利用链下签名作为白名单发放NFT的方法。如果你对ECDSA合约不熟悉,请看WTF Solidity极简教程第37讲:数字签名

数字签名

如果你用过opensea交易NFT,对签名就不会陌生。下图是小狐狸(metamask)钱包进行签名时弹出的窗口,它可以证明你拥有私钥的同时不需要对外公布私钥。

metamask签名

以太坊使用的数字签名算法叫双椭圆曲线数字签名算法(ECDSA),基于双椭圆曲线“私钥-公钥”对的数字签名算法。它主要起到了三个作用

  1. 身份认证:证明签名方是私钥的持有人。
  2. 不可否认:发送方不能否认发送过这个消息。
  3. 完整性:消息在传输过程中无法被修改。

数字签名合约简述

WTF Solidity极简教程第37讲:数字签名中的SignatureNFT合约利用ECDSA验证白名单铸造NFT。我们讲下两个重要的函数:

  1. 构造函数:初始化NFT的名称,代号,和签名公钥signer

  2. mint():利用ECDSA验证白名单地址并铸造。参数为白名单地址account,铸造的tokenId,和签名signature

生成数字签名

  1. 打包消息:在以太坊的ECDSA标准中,被签名的消息是一组数据的keccak256哈希,为bytes32类型。我们可以利用ethers.js提供的solidityKeccak256()函数,把任何想要签名的内容打包并计算哈希。等效于solidity中的keccak256(abi.encodePacked()

    在下面的代码中,我们将一个address类型变量和一个uint256类型变量打包后哈希,得到消息

    // 创建消息
    const account = "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4"
    const tokenId = "0"
    // 等效于Solidity中的keccak256(abi.encodePacked(account, tokenId))
    const msgHash = utils.solidityKeccak256(
    ['address', 'uint256'],
    [account, tokenId])
    console.log(`msgHash:${msgHash}`)
    // msgHash:0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c
  2. 签名:为了避免用户误签了恶意交易,EIP191提倡在消息前加上"\x19Ethereum Signed Message:\n32"字符,再做一次keccak256哈希得到以太坊签名消息,然后再签名。ethers.js的钱包类提供了signMessage()函数进行符合EIP191标准的签名。注意,如果消息string类型,则需要利用arrayify()函数处理下。例子:

    // 签名
    const messageHashBytes = ethers.utils.arrayify(msgHash)
    const signature = await wallet.signMessage(messageHashBytes);
    console.log(`签名:${signature}`)
    // 签名:0x390d704d7ab732ce034203599ee93dd5d3cb0d4d1d7c600ac11726659489773d559b12d220f99f41d17651b0c1c6a669d346a397f8541760d6b32a5725378b241c

链下签名白名单铸造NFT

  1. 创建providerwallet,其中wallet是用于签名的钱包。

    // 准备 alchemy API 可以参考https://github.com/AmazingAng/WTFSolidity/blob/main/Topics/Tools/TOOL04_Alchemy/readme.md 
    const ALCHEMY_RINKEBY_URL = 'https://eth-rinkeby.alchemyapi.io/v2/GlaeWuylnNM3uuOo-SAwJxuwTdqHaY5l';
    const provider = new ethers.providers.JsonRpcProvider(ALCHEMY_RINKEBY_URL);
    // 利用私钥和provider创建wallet对象
    const privateKey = '0x227dbb8586117d55284e26620bc76534dfbd2394be34cf4a09cb775d593b6f2b'
    const wallet = new ethers.Wallet(privateKey, provider)
  2. 根据白名单地址和他们能铸造的tokenId生成消息并签名。

    // 创建消息
    const account = "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4"
    const tokenId = "0"
    // 等效于Solidity中的keccak256(abi.encodePacked(account, tokenId))
    const msgHash = utils.solidityKeccak256(
    ['address', 'uint256'],
    [account, tokenId])
    console.log(`msgHash:${msgHash}`)
    // 签名
    const messageHashBytes = ethers.utils.arrayify(msgHash)
    const signature = await wallet.signMessage(messageHashBytes);
    console.log(`签名:${signature}`)

    创建签名

  3. 创建合约工厂,为部署NFT合约做准备。

    // NFT的人类可读abi
    const abiNFT = [
    "constructor(string memory _name, string memory _symbol, address _signer)",
    "function name() view returns (string)",
    "function symbol() view returns (string)",
    "function mint(address _account, uint256 _tokenId, bytes memory _signature) external",
    "function ownerOf(uint256) view returns (address)",
    "function balanceOf(address) view returns (uint256)",
    ];
    // 合约字节码,在remix中,你可以在两个地方找到Bytecode
    // i. 部署面板的Bytecode按钮
    // ii. 文件面板artifact文件夹下与合约同名的json文件中
    // 里面"object"字段对应的数据就是Bytecode,挺长的,608060起始
    // "object": "608060405260646000553480156100...
    const bytecodeNFT = contractJson.default.object;
    const factoryNFT = new ethers.ContractFactory(abiNFT, bytecodeNFT, wallet);
  4. 利用合约工厂部署NFT合约。

    // 部署合约,填入constructor的参数
    const contractNFT = await factoryNFT.deploy("WTF Signature", "WTF", wallet.address)
    console.log(`合约地址: ${contractNFT.address}`);
    // console.log("部署合约的交易详情")
    // console.log(contractNFT.deployTransaction)
    console.log("等待合约部署上链")
    await contractNFT.deployed()
    // 也可以用 contractNFT.deployTransaction.wait()
    console.log("合约已上链")

    部署NFT合约

  5. 调用NFT合约的mint()函数,利用链下签名验证白名单,为account地址铸造NFT

    console.log(`NFT名称: ${await contractNFT.name()}`)
    console.log(`NFT代号: ${await contractNFT.symbol()}`)
    let tx = await contractNFT.mint(account, tokenId, signature)
    console.log("铸造中,等待交易上链")
    await tx.wait()
    console.log(`mint成功,地址${account} 的NFT余额: ${await contractNFT.balanceOf(account)}\n`)

    验证签名并铸造NFT

用于生产环境

在生产环境使用数字签名验证白名单发行NFT主要有以下步骤:

  1. 确定白名单列表。
  2. 在后端维护一个签名钱包,生成每个白名单对应的消息签名
  3. 部署NFT合约,并将签名钱包的公钥signer保存在合约中。
  4. 用户铸造时,向后端请求地址对应的签名
  5. 用户调用mint()函数进行铸造NFT

总结

这一讲,我们介使用ether.js配合智能合约,以链下数字签名的方式验证白名单并发行NFTMerkle Tree和链下数字签名是目前最主流也最经济的发放白名单方式。如果合约部署的时候已经确定好白名单列表,那么建议用Merkle Tree;如果在合约部署之后要不断添加白名单,例如Galaxy Project的OAT,那么建议用链下签名的方式,不然就要不断更新合约中Merkle Treeroot,花费gas。