You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Solidity合约测试中带签名校验的Mint签名生成方案

问题描述

为基于Ethier工具库开发的ERC721合约编写测试用例时,无法生成符合合约签名校验规则的有效签名,保留签名校验逻辑时测试无法正常执行。

合约实现代码

// SPDX-License-Identifier: UNLICENSED
pragma solidity >=0.8.9 <0.9.0;

import "@divergencetech/ethier/contracts/crypto/SignatureChecker.sol";
import "@divergencetech/ethier/contracts/crypto/SignerManager.sol";
import "@divergencetech/ethier/contracts/erc721/BaseTokenURI.sol";
import "@divergencetech/ethier/contracts/erc721/ERC721ACommon.sol";
import "@divergencetech/ethier/contracts/erc721/ERC721Redeemer.sol";
import "@divergencetech/ethier/contracts/sales/FixedPriceSeller.sol";
import "@divergencetech/ethier/contracts/utils/Monotonic.sol";
import "@openzeppelin/contracts/token/common/ERC2981.sol";
import "@openzeppelin/contracts/access/AccessControlEnumerable.sol";
import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";

interface ITokenURIGenerator {
    function tokenURI(uint256 tokenId) external view returns (string memory);
}

// @author divergence.xyz
contract TestBirds is
    ERC721ACommon,
    BaseTokenURI,
    FixedPriceSeller,
    ERC2981,
    AccessControlEnumerable
{
    using EnumerableSet for EnumerableSet.AddressSet;   
    using ERC721Redeemer for ERC721Redeemer.Claims;
    using Monotonic for Monotonic.Increaser;

    bytes32 public constant EXPULSION_ROLE = keccak256("EXPULSION_ROLE");

    constructor(
        string memory name,
        string memory symbol,
        string memory baseTokenURI,
        address payable beneficiary,
        address payable royaltyReceiver
    )
        ERC721ACommon(name, symbol)
        BaseTokenURI(baseTokenURI)
        FixedPriceSeller(
            2.5 ether,
            Seller.SellerConfig({
                totalInventory: 10_000,
                lockTotalInventory: true,
                maxPerAddress: 0,
                maxPerTx: 0,
                freeQuota: 125,
                lockFreeQuota: false,
                reserveFreeQuota: true
            }),
            beneficiary
        )
    {
        _setDefaultRoyalty(royaltyReceiver, 1000);
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
    }

    function _handlePurchase(
        address to,
        uint256 n,
        bool
    ) internal override {
        _safeMint(to, n);
    }

    mapping(bytes32 => bool) public usedMessages;

    function mintPublic(
        address to,
        bytes32 nonce,
        bytes calldata sig
    ) external payable {
        signers.requireValidSignature(
            signaturePayload(to, nonce),
            sig,
            usedMessages
        );
        _purchase(to, 1);
    }

    function alreadyMinted(address to, bytes32 nonce)
        external
        view
        returns (bool)
    {
        return
            usedMessages[
                SignatureChecker.generateMessage(signaturePayload(to, nonce))
            ];
    }

    function signaturePayload(address to, bytes32 nonce)
        internal
        pure
        returns (bytes memory)
    {
        return abi.encodePacked(to, nonce);
    }


    function _baseURI()
        internal
        view
        override(BaseTokenURI, ERC721A)
        returns (string memory)
    {
        return BaseTokenURI._baseURI();
    }

    ITokenURIGenerator public renderingContract;

    function setRenderingContract(ITokenURIGenerator _contract)
        external
        onlyOwner
    {
        renderingContract = _contract;
    }

    function tokenURI(uint256 tokenId)
        public
        view
        override
        returns (string memory)
    {
        if (address(renderingContract) != address(0)) {
            return renderingContract.tokenURI(tokenId);
        }
        return super.tokenURI(tokenId);
    }

    function setRoyaltyInfo(address receiver, uint96 feeBasisPoints)
        external
        onlyOwner
    {
        _setDefaultRoyalty(receiver, feeBasisPoints);
    }

    function supportsInterface(bytes4 interfaceId)
        public
        view
        override(ERC721ACommon, ERC2981, AccessControlEnumerable)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

原有测试代码

const { expect } = require('chai');

describe("TestBirds", function () {
    it ("Should return correct name, URI, owner and beneficiary", async function () {

        const [owner, addr1] = await hre.ethers.getSigners()
               
        provider = ethers.provider

        const TestBirdsContract = await hre.ethers.getContractFactory("TestBirds")
        const testBirdsContractDeployed = await TestBirdsContract.deploy(
            "TestBirds",
            "APFP",
            "https://test.url/",
            owner.address,
            owner.address)

        console.log(await provider.getBalance(owner.address));
        await testBirdsContractDeployed.deployed()

        const nonce = await ethers.provider.getTransactionCount(owner.address, "latest")
        await testBirdsContractDeployed.mintPublic(owner.address, nonce, signature???)
       
        expect(await testBirdsContractDeployed.name()).to.equal("TestBirds")
        expect(await testBirdsContractDeployed.tokenURI(0), "https://test.url/0")
        expect(await testBirdsContractDeployed.ownerOf(0)).to.equal(owner.address)
    })
})
解决方案

原有代码存在3个核心问题导致签名校验不通过:

  • 合约继承的SignerManager初始签名者列表为空,部署后没有将签名使用的地址加入白名单,就算签名格式正确也会被判定为无效
  • 签名payload拼接、签名逻辑没有对齐合约内的校验规则:Ethier的SignatureChecker采用标准ERC191个人签名方案,和ethers.js的signMessage逻辑完全兼容,但需要严格按照合约内abi.encodePacked(to, nonce)的规则拼接原始待签名数据
  • 调用mintPublic时没有携带足够的ETH,公售单价为2.5ETH,未传value参数时_purchase逻辑会直接报错;另外用链上交易计数当nonce容易出现重复,测试中直接生成随机bytes32即可。

修复后可运行的测试代码

const { expect } = require('chai');
const { ethers } = require("hardhat");

describe("TestBirds", function () {
    it ("Should mint successfully with valid signature", async function () {
        const [owner, minter] = await ethers.getSigners();
        const MINT_PRICE = ethers.utils.parseEther("2.5");

        const TestBirds = await ethers.getContractFactory("TestBirds");
        const contract = await TestBirds.deploy(
            "TestBirds",
            "APFP",
            "https://test.url/",
            owner.address,
            owner.address
        );
        await contract.deployed();

        // 将签名者地址(测试用owner即可)加入合约签名白名单
        await contract.addSigner(owner.address);

        // 生成随机nonce避免重复
        const nonce = ethers.utils.randomBytes(32);
        const minterAddress = minter.address;

        // 严格对齐合约abi.encodePacked(to, nonce)规则拼接待签名数据
        const payload = ethers.utils.solidityPack(
            ["address", "bytes32"],
            [minterAddress, nonce]
        );

        // 用签名者钱包生成符合ERC191标准的个人签名
        const signature = await owner.signMessage(ethers.utils.arrayify(payload));

        // 调用铸造方法,传入正确参数并附带足够的铸造费用
        await contract.connect(minter).mintPublic(
            minterAddress,
            nonce,
            signature,
            { value: MINT_PRICE }
        );

        // 校验铸造结果
        expect(await contract.name()).to.equal("TestBirds");
        expect(await contract.tokenURI(0)).to.equal("https://test.url/0");
        expect(await contract.ownerOf(0)).to.equal(minterAddress);
        expect(await contract.alreadyMinted(minterAddress, nonce)).to.equal(true);
    })
})

测试中如果需要用其他地址当签名者,只需要把对应地址加入签名白名单,用对应地址的signer执行签名即可,逻辑完全一致。生产环境下签名私钥不要和部署管理员私钥混用,避免私钥泄露导致盗铸。

内容的提问来源于stack exchange,提问作者Matias Villanueva

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.28 08:39:19