18. 数字签名脚本
这一讲,我们介绍一个利用链下签名作为白名单发放NFT的方法。
数字签名
如果你用过opensea交易NFT,对签名就不会陌生。下图是小狐狸(metamask)钱包进行签名时弹出的窗口,它可以证明你拥有私钥的同时不需要对外公布私钥。

以太坊使用的数字签名算法叫双椭圆曲线数字签名算法(ECDSA),基于双椭圆曲线“私钥-公钥”对的数字签名算法。它主要起到了三个作用:
- 身份认证:证明签名方是私钥的持有人。
- 不可否认:发送方不能否认发送过这个消息。
- 完整性:消息在传输过程中无法被修改。
数字签名合约简述
SignatureNFT合约利用ECDSA验证白名单铸造NFT。我们讲下两个重要的函数:
构造函数:初始化NFT的名称,代号,和签名公钥
signer。mint():利用ECDSA验证白名单地址并铸造。参数为白名单地址account,铸造的tokenId,和签名signature。
生成数字签名
打包消息:在以太坊的
ECDSA标准中,被签名的消息是一组数据的keccak256哈希,为bytes32类型。我们可以利用ethers.js提供的solidityPackedKeccak256()函数,把任何想要签名的内容打包并计算哈希。等效于solidity中的keccak256(abi.encodePacked())。在下面的代码中,我们将一个
address类型变量和一个uint256类型变量打包后哈希,得到消息:// 创建消息 const account = "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4" const tokenId = "0" // 等效于Solidity中的keccak256(abi.encodePacked(account, tokenId)) const msgHash = ethers.solidityPackedKeccak256( ['address', 'uint256'], [account, tokenId]) console.log(`msgHash:${msgHash}`) // msgHash:0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c签名:为了避免用户误签了恶意交易,
EIP191提倡在消息前加上"\x19Ethereum Signed Message:\n32"字符,再做一次keccak256哈希得到以太坊签名消息,然后再签名。ethers.js的钱包类提供了signMessage()函数进行符合EIP191标准的签名。注意,如果消息为string类型,则需要利用arrayify()函数处理下。例子:// 签名 const messageHashBytes = ethers.getBytes(msgHash) const signature = await wallet.signMessage(messageHashBytes); console.log(`签名:${signature}`) // 签名:0x390d704d7ab732ce034203599ee93dd5d3cb0d4d1d7c600ac11726659489773d559b12d220f99f41d17651b0c1c6a669d346a397f8541760d6b32a5725378b241c
链下签名白名单铸造NFT
创建
provider和wallet,其中wallet是用于签名的钱包。const ALCHEMY_GOERLI_URL = 'https://eth-goerli.alchemyapi.io/v2/GlaeWuylnNM3uuOo-SAwJxuwTdqHaY5l'; const provider = new ethers.JsonRpcProvider(ALCHEMY_GOERLI_URL); // 利用私钥和provider创建wallet对象 const privateKey = '0x227dbb8586117d55284e26620bc76534dfbd2394be34cf4a09cb775d593b6f2b' const wallet = new ethers.Wallet(privateKey, provider)根据白名单地址和他们能铸造的
tokenId生成消息并签名。// 创建消息 const account = "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4" const tokenId = "0" // 等效于Solidity中的keccak256(abi.encodePacked(account, tokenId)) const msgHash = ethers.solidityPackedKeccak256( ['address', 'uint256'], [account, tokenId]) console.log(`msgHash:${msgHash}`) // 签名 const messageHashBytes = ethers.getBytes(msgHash) const signature = await wallet.signMessage(messageHashBytes); console.log(`签名:${signature}`)
创建合约工厂,为部署
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);利用合约工厂部署NFT合约。
// 部署合约,填入constructor的参数 const contractNFT = await factoryNFT.deploy("WTF Signature", "WTF", wallet.address) console.log(`合约地址: ${contractNFT.target}`); console.log("等待合约部署上链") await contractNFT.waitForDeployment() // 也可以用 contractNFT.deployTransaction.wait() console.log("合约已上链")
调用
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合约,并将签名钱包的公钥signer保存在合约中。 - 用户铸造时,向后端请求地址对应的
签名。 - 用户调用
mint()函数进行铸造NFT。
完整代码
// 通过签名分发NFT白名单流程:
//
// 在服务器保管signer钱包的私钥-公钥对
// -> 在服务器记录allowlist(白名单地址)和tokenId,并生成对应的msgHash,
// -> 用signer钱包给msgHash签名
// -> 部署NFT合约,初始化时signer的公钥保存在合约中。
// -> 用户mint时填地址和tokenId,并向服务器请求签名。
// -> 调用合约的mint()函数进行铸造
import { ethers } from "ethers";
import * as contractJson from "./contract.json" assert {type: "json"};
// 1. 创建provider和wallet
const ALCHEMY_GOERLI_URL = 'https://eth-goerli.alchemyapi.io/v2/GlaeWuylnNM3uuOo-SAwJxuwTdqHaY5l';
const provider = new ethers.JsonRpcProvider(ALCHEMY_GOERLI_URL);
// 利用私钥和provider创建wallet对象
const privateKey = '0x227dbb8586117d55284e26620bc76534dfbd2394be34cf4a09cb775d593b6f2b'
const wallet = new ethers.Wallet(privateKey, provider)
// 2. 根据allowlist地址和tokenId生成msgHash,并签名
console.log("\n1. 生成签名")
// 创建消息
const account = "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4"
const tokenId = "0"
// 等效于Solidity中的keccak256(abi.encodePacked(account, tokenId))
const msgHash = ethers.solidityPackedKeccak256(
['address', 'uint256'],
[account, tokenId])
console.log(`msgHash:${msgHash}`)
const main = async () => {
// 签名
const messageHashBytes = ethers.getBytes(msgHash)
const signature = await wallet.signMessage(messageHashBytes);
console.log(`签名:${signature}`)
// 3. 创建合约工厂
// 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);
// 读取钱包内ETH余额
const balanceETH = await provider.getBalance(wallet)
// 如果钱包ETH足够
if(ethers.formatEther(balanceETH) > 0.002){
// 4. 利用contractFactory部署NFT合约
console.log("\n2. 利用contractFactory部署NFT合约")
// 部署合约,填入constructor的参数
const contractNFT = await factoryNFT.deploy("WTF Signature", "WTF", wallet.address)
console.log(`合约地址: ${contractNFT.target}`);
console.log("等待合约部署上链")
await contractNFT.waitForDeployment()
// 也可以用 contractNFT.deployTransaction.wait()
console.log("合约已上链")
// 5. 调用mint()函数,利用签名验证白名单,给account地址铸造NFT
console.log("\n3. 调用mint()函数,利用签名验证白名单,给第一个地址铸造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`)
}else{
// 如果ETH不足
console.log("ETH不足,去水龙头领一些Goerli ETH")
console.log("1. chainlink水龙头: https://faucets.chain.link/goerli")
console.log("2. paradigm水龙头: https://faucet.paradigm.xyz/")
}
}
main()
