数字签名(ECDSA)与 NFT 白名单
一、什么是数字签名?
数字签名是密码学中的一种验证机制。在以太坊中,它可以用来证明某个消息确实是由某个特定地址(私钥持有者)签发的,而无需暴露私钥。
生活中的类比
想象你在银行办业务需要签字。银行通过对比你的签字与预留签字来确认是你本人。数字签名类似——只不过是数学上的”签字”,不可伪造、不可抵赖。
二、核心概念
| 概念 | 说明 |
|---|---|
| 私钥 | 只有你自己知道的秘密数字,用来签名 |
| 公钥/地址 | 从私钥推导出来的,可以公开,用来验证签名 |
| 消息哈希 | 对要签名的内容取哈希(keccak256),固定长度 32 字节 |
| 签名(signature) | 用私钥对消息哈希进行运算得到的 65 字节数据(r + s + v) |
| ecrecover | Solidity 内置函数,从签名和消息哈希反推出签名者地址 |
三、签名的完整流程
┌─────────────────────────────────────────────────────────────┐
│ 链下(前端/钱包) │
├─────────────────────────────────────────────────────────────┤
│ 1. 构造消息: hash = keccak256(用户地址 + tokenId) │
│ 2. 加前缀: ethHash = "\x19Ethereum Signed Message:\n32" + hash │
│ 3. 用私钥签名 ethHash → 得到 signature (65 bytes) │
└─────────────────────────────────────────────────────────────┘
│
▼ 提交 signature 到合约
┌─────────────────────────────────────────────────────────────┐
│ 链上(智能合约) │
├─────────────────────────────────────────────────────────────┤
│ 1. 重新计算消息哈希(用相同的参数) │
│ 2. 加上以太坊签名前缀 │
│ 3. 用 ecrecover(ethHash, v, r, s) 恢复签名者地址 │
│ 4. 对比恢复出的地址与项目方预设的 signer 地址 │
│ 5. 一致 → 验证通过 → 允许 mint NFT │
└─────────────────────────────────────────────────────────────┘
四、为什么要加以太坊前缀?
"\x19Ethereum Signed Message:\n32"
这是 EIP-191 标准规定的前缀。目的是防止用户签名的消息被恶意利用为真实的以太坊交易。加了这个前缀后,签出来的签名只能用于消息验证,不会被当作转账交易执行。
五、代码详解
5.1 签名工具库
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
library Signature {
/// @notice 将地址和 tokenId 打包取哈希,作为原始消息
function getMessageHash(
address _account,
uint256 _tokenId
) public pure returns (bytes32) {
return keccak256(abi.encodePacked(_account, _tokenId));
}
/// @notice 在消息哈希前加以太坊签名前缀(EIP-191)
function toEthSignedMessageHash(
bytes32 messageHash
) public pure returns (bytes32) {
return keccak256(
abi.encodePacked(
"\x19Ethereum Signed Message:\n32",
messageHash
)
);
}
/// @notice 从签名中恢复签名者地址
function recoverPublicKey(
bytes32 _messageHash,
bytes memory _signature
) public pure returns (address) {
require(_signature.length == 65, "invalid signature length");
bytes32 r;
bytes32 s;
uint8 v;
// 用内联汇编从 signature 中提取 r, s, v
assembly {
r := mload(add(_signature, 0x20)) // 前 32 字节
s := mload(add(_signature, 0x40)) // 中间 32 字节
v := byte(0, mload(add(_signature, 0x60))) // 最后 1 字节
}
return ecrecover(_messageHash, v, r, s);
}
/// @notice 验证签名是否来自指定地址
function verify(
bytes32 _messageHash,
bytes memory _signature,
address _publicKey
) public pure returns (bool) {
return recoverPublicKey(_messageHash, _signature) == _publicKey;
}
}
5.2 签名 NFT 白名单合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./ERC721.sol";
contract SignatureNFT is ERC721 {
// 项目方的签名地址(公钥),部署时写入,不可更改
address public immutable signer;
// 记录已经 mint 过的地址,防止重复铸造
mapping(address => bool) public mintedAddress;
constructor(
string memory name,
string memory symbol,
address _signer
) ERC721(name, symbol) {
signer = _signer;
}
/// @notice 用户提交签名来铸造 NFT
function mint(
address _account,
uint256 _tokenId,
bytes memory _signature
) external {
// 步骤1: 计算消息哈希
bytes32 _messageHash = getMessageHash(_account, _tokenId);
// 步骤2: 加以太坊前缀
bytes32 _ethSignedHash = toEthSignedMessageHash(_messageHash);
// 步骤3: 验证签名是否来自项目方 signer
require(verify(_ethSignedHash, _signature), "Invalid Signature!");
// 步骤4: 防止重复铸造
require(!mintedAddress[_account], "Already minted!");
// 步骤5: 铸造 NFT
_mint(_account, _tokenId);
mintedAddress[_account] = true;
}
// ... 内部辅助函数(同上面的库)
}
六、签名中 r、s、v 是什么?
一个 ECDSA 签名是 65 字节,由三部分组成:
| 分量 | 长度 | 含义 |
|---|---|---|
r | 32 字节 | 椭圆曲线上随机点的 x 坐标 |
s | 32 字节 | 签名计算结果 |
v | 1 字节 | 恢复标识符(27 或 28),帮助确定公钥 |
在内存中的排列:[r (32 bytes)][s (32 bytes)][v (1 byte)]
七、实际使用场景
场景:NFT 白名单(免 Gas 方案)
传统方式(Merkle Tree)需要项目方提前将所有白名单地址上链,有新增或修改就要重新设置根哈希。
签名方案的优势:
- 项目方在链下用私钥为每个白名单用户签名
- 用户 mint 时提交签名到合约
- 合约验证签名是否来自项目方
- 无需提前上链任何白名单数据,省 gas、更灵活
前端签名示例(ethers.js)
const { ethers } = require("ethers");
// 项目方私钥(服务器端保管,绝不能暴露!)
const signer = new ethers.Wallet("项目方私钥");
// 为用户生成签名
async function generateSignature(userAddress, tokenId) {
// 1. 构造消息(与合约中 getMessageHash 逻辑一致)
const messageHash = ethers.solidityPackedKeccak256(
["address", "uint256"],
[userAddress, tokenId]
);
// 2. 签名(ethers.js 会自动加以太坊前缀)
const signature = await signer.signMessage(ethers.getBytes(messageHash));
return signature;
}
八、安全注意事项
- 私钥保管:签名私钥必须安全存储在服务器端,泄露等于白名单失效
- 防重放:合约中用
mintedAddress映射防止同一地址重复使用签名 - 前缀不可省略:不加 EIP-191 前缀,签名可能被恶意用作交易
- 签名长度校验:必须检查签名长度为 65 字节
九、总结
| 对比项 | Merkle Tree 白名单 | 签名白名单 |
|---|---|---|
| 上链成本 | 需要存储 Merkle Root | 只需存一个 signer 地址 |
| 灵活性 | 修改名单需重新设置根 | 随时可以签发新签名 |
| 链下准备 | 需要构造整棵树 | 单独为每个用户签名即可 |
| 安全性 | 高 | 高(依赖私钥安全) |