NFT白名单校验-签名验证

前面的文章我们介绍过,NFT 白名单校验主要有两种方式:

  • Merkle Tree
  • 签名验证

这篇文章我们就来介绍一下签名验证的实现方法。我们这篇文章不会注重于签名的数学原理,仅仅对于链上与链下交互的实现做介绍。

在开始之前,我们先要了解一个基础知识:我们平时使用的区块链地址,都分为私钥和地址。私钥可以生成地址,而反过来,地址不能生成私钥。那么如何证明一个地址是属于某一个人呢?我们可以给他一段信息,让他用私钥对这段信息进行签名。我们拿到这段签名,再结合这个地址,就可以证明,这段信息是否是由这个地址对应的私钥签名,从而也就证明了地址的所有权。

验证步骤

明白了这个简单的原理之后,我们来看看实际项目中的白名单签名验证是什么步骤:

  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;
    }
}

链上逻辑实现后,我们还要在链下对信息进行签名。我们使用 ethers 的 JavaScript 库进行签名操作,需要安装 ethers 的依赖:

npm install --save-dev ethers

代码:

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 进行签名
    // 因此这里需要改为合约的调用地址
    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 方法,就可以验证签名信息的准确性。

我们这里的签名信息仅仅是对合约发送者进行签名,这可能会造成签名信息被重复利用从而造成重放攻击。因此我们一般在实际项目开发中都会加上一些其他的信息,比如合约本身的地址(这样签名就确认只能应用于这个合约),或者盐(随机数确保随机)。

如果加上合约本身的地址作源信息,那么代码就需要改成:

// Solidity
bytes32 messageHash = keccak256(abi.encodePacked(address(this), msg.sender));

// Javascript
let messageHash = ethers.utils.solidityKeccak256(['address', 'address'], [message, message]);

关于 solidityKeccak256 的使用方法可以参考文档

加盐的方法与上面类似,小幽灵(The Weirdo Ghost Gang)这个项目代码的白名单验证实现中就加入的盐的信息:

总结

签名验证的数学逻辑比较复杂,但是已经有成熟的库将其封装好了,对于我们实际使用来说,难度不大,基本都是套路,多看看代码,多写几遍就很熟练了。

参考(这几篇文章和视频的内容都很精华,时间充裕的话建议都读一遍)

Subscribe to xyyme.eth
Receive the latest updates directly to your inbox.
Verification
This entry has been permanently stored onchain and signed by its creator.