如何在 EDU Chain 上铸造一枚可升级的学习证明

176 阅读15分钟

继续跟着 HackQuest 学习合约知识

HackQuest

image.png

HackQuest 自己的合约编辑器

image.png

关键内容

我们将以 EDU Chain 为基础,打造一个可升级的学习证明。在这里,用户每次开始学习新课程时,都会获得一个独特的 NFT,并且随着学习进度的提升,这个 NFT 也会同步升级,激励用户持续探索、深入学习。通过这些区块链技术的应用,你将体验如何通过创新的激励机制提升学习的趣味性和成就感。

在这个系列课程中,我们将系统学习以下关键内容:

●OpenZeppelin 的 ERC721 库:掌握如何使用这一流行的库来创建独一无二的 NFT。

●为你的 NFT 升级元数据:解锁升级 NFT 的技巧,使用户的成就随进步而不断展现。

●生成数字签名:使用 personal_sign JSON RPC 方法来生成签名,为用户创建专属的、可验证的学习记录。

●验证签名:通过 ECDSA 库验证签名,确保每个学习进度的真实性和安全性。

●在 EDU Chain 上部署和验证智能合约:最终,我们将合约部署到 EDU Chain 上,让这一切运转起来。

openZeppelin

OpenZeppelin 的合约库提供了多种安全、经过审计的智能合约基础模块。通过 ERC721 和 ERC721URIStorage,我们能够轻松创建、存储和管理 NFT。

●ERC721:这是最基础的 ERC721 标准实现,用于创建不可替代的代币(NFT)。

●ERC721URIStorage:这是 ERC721 的一个扩展模块,增加了对每个 token 独立 URI 的存储功能,允许我们在合约中实现动态元数据。

●Ownable:这是权限管理模块。继承 Ownable 合约后,合约的所有者拥有特定权限(例如,铸造 NFT 或更改 URI),从而提高安全性。

import 是 Solidity 中用于引入外部文件或库的关键字。通过 import,我们可以直接使用第三方库的代码,而无需手动将其包含在同一个文件中。这种机制极大地提高了代码的复用性和可维护性,同时也便于我们使用像 OpenZeppelin 这样的成熟且安全的合约库。

引入后我们就可以通过继承来复用或扩展父合约的功能,我们通过 is 语法来实现这一点。

我们可以通过如下的工具(docs.openzeppelin.com/contracts/5… ERC721 合约的模板代码:

image.png

using xxLib for xxType

我们将以另一种方式引入 ECDSA 这个库。

ECDSA(椭圆曲线数字签名算法)是一种常见的加密算法,用于签名和验证数据,确保数据的完整性和签名者的身份。OpenZeppelin 提供了 ECDSA 库(位于 @openzeppelin/contracts/utils/cryptography/ECDSA.sol ),用于在智能合约中实现签名的恢复和验证,以确保消息或交易数据的合法性,防止篡改或伪造。

语法

using ... for … 是 Solidity 中用于绑定库函数的语法。通过 using ... for 语句,我们可以将库中的函数直接应用于特定的数据类型,就像它们是该数据类型的成员函数一样。

例如使用 Strings 这个库绑定 unit256 类型的数据 using Strings for uint256; 这样 uint256 类型的数据可以直接调用 Strings 库中的函数。例如,如果我们需要将数值转为字符串,可以直接写成 _value.toString(),而不是 Strings.toString(_value),这样可以简化代码,使其更具可读性。

状态变量是 Solidity 合约中存储在区块链上的数据。这些变量不仅定义了合约的核心数据结构,还影响合约逻辑的执行流程。HackQuest 合约中的状态变量包含数组、枚举、基本数据类型和映射等。

数组定义

在合约中,ipfsUris 变量定义了一个 string 类型的固定长度数组,长度为 3,固定长度数组比动态数组更节省存储空间和 gas 消耗:

 string[3] private ipfsUris = [
    "ipfs://QmaWKA1oQ7DMurioZ7fpyJWF8jkZN4w43bKmsYS8hgQQoT",
    "ipfs://QmTsxJitqqqfJYYvEU3q3ogKMadU8mnbtWWwtNg9BkcVXa",
    "ipfs://QmZooiszjSdiGpG2ZUKjrPykHaMjMKRFTigxY2HMVMYdYv"
];

在这个项目中,每个 URI 可以对应 NFT 在不同进度状态下的元数据文件,这些文件存储在链下,通常是去中心化的文件系统 IPFS 上,例如我们可以通过 Pinata 进行管理,使用如下方式上传所需的元数据文件,并得到 ipfs://xxx 这样的访问地址。

img

定义枚举

合约中定义了一个枚举类型 CourseProgress,用于表示课程进度的不同状态:

  enum CourseProgress {
    INIT, // Just started
    PROGRESS_HALF, // halfway through the course
    PROGRESS_COMPLETE // completed all courses
}

这里使用 enum 关键字声明枚举类型,{INIT, PROGRESS_HALF, PROGRESS_COMPLETE} 为枚举成员列表,每个成员自动分配一个从 0 开始的整数值,例如 INIT 为 0,PROGRESS_HALF 为 1,依此类推。

IPFS 和 Pinata

IPFS(InterPlanetary File System) 是一个点对点分布式文件系统,它允许用户存储和共享文件,而无需依赖单个服务器。IPFS 通过将文件分解成块,并使用内容寻址(content addressing)的方式来标识和检索这些块。每个块都有一个唯一的哈希值,该哈希值由其内容决定。这意味着即使文件被复制到多个节点上,它们仍然可以通过其哈希值被唯一地识别和访问。

Pinata 则是一个基于 IPFS 的服务,它简化了在 IPFS 网络上存储和管理文件的过程。它提供了一个用户友好的界面和 API,允许用户轻松地将文件上传到 IPFS 网络,并获得这些文件的链接。

简单来说:

●IPFS 是底层协议和技术,它定义了如何存储和访问去中心化文件。

●Pinata 是一个构建在 IPFS 之上的服务,它简化了使用 IPFS 的过程。

什么是 NFT 元数据

NFT 元数据是描述代币属性的结构化数据,用于展示,包括图像、名称、描述和其他可定制属性。元数据通常是 JSON 格式,存储在链下(如 IPFS 中),并通过 tokenURI 函数对外提供。元数据的设计对 NFT 的外观和用户体验有直接影响。

img

典型的 NFT 元数据 JSON 结构如下:

{
  "name": "My NFT Token #1",
  "description": "This is a unique NFT with customizable metadata.",
  "image": "ipfs://QmExampleHash",
  "attributes": [
    { "trait_type": "Background", "value": "Blue" },
    { "trait_type": "Level", "value": 5 }
  ]
}

在 OpenSea 等市场上,NFT 元数据通过 trait_type 和 value 表达额外的属性,使收藏者能够直观地查看 NFT 的稀有度和独特性。

img

如何配置元数据

1.注册 Pinata 账户:app.pinata.cloud/

2.上传 NFT 图像到 Pinata:在控制台点击 “Upload”,选择要上传的图像文件,上传完成后,Pinata 将为图像生成一个 IPFS 哈希值(CID)。

3.获取图像的 IPFS 链接:Pinata 会为上传的文件提供 IPFS CID,可以使用 gateway.pinata.cloud/ipfs/ 格式的链接来访问文件,我们只需要保留 ipfs:// 的部分,在下一步使用。

4.创建元数据文件:使用文本编辑器创建一个 JSON 文件,填写 NFT 的属性,并将 image 字段的值设置为刚才获取的 IPFS 图像链接。例如:

{
  "attributes": [
    {
      "trait_type": "Level",
      "value": "0"
    },
    {
      "trait_type": "Experience",
      "value": 0
    }
  ],
  "description": "Welcome to Web3 Learning Platform, Start your learning journey~",
  "image": "ipfs://QmVkqTDUnXGLLGPnPFkhgxRvGdYPcsK3RgmmfwimjrL8km",
  "name": "Beginner"
}

5.上传 JSON 文件到 Pinata:回到 Pinata 控制台,上传刚才创建的 JSON 文件(例如 metadata.json),同样会生成一个 IPFS CID(例如 ipfs://QmRvHSjTzRv9AwyKauhpzj9CZr2mfjGrgcsxahM3qLEixg),这个 CID 就是 NFT 的元数据链接,配置在上一节的 ipfsUris 这个状态变量中。请至少配置 3 个元数据。

构造函数

构造函数是一种特殊的函数,只在合约部署时执行一次,用于对合约的状态变量进行初始化。

以下是一个简单的构造函数示意:

constructor(address initialOwner)
	  ERC721("HackQuest", "HQ")
	  Ownable(initialOwner)
{}

构造函数以 constructor 关键字定义。该构造函数接受一个 address 类型的参数 initialOwner。这个参数在合约部署时由部署者指定,用于设置初始拥有者或管理员权限的地址。

如果一个合约继承了父合约,那么可以在构造函数中调用父合约的构造函数进行初始化。该示例中,合约继承了 OpenZeppelin 的 ERC721 和 Ownable 合约,因此构造函数调用了这两个父合约的构造函数来完成初始化。

铸造

另一个非常重要的函数:铸造。

在 ERC721 标准中,mint 是生成新 NFT 的基本操作。OpenZeppelin 提供了 safeMint 方法,以增强 mint 的安全性。与 mint 不同,safeMint 会在代币转移给接收者之前检查目标地址是否实现了 ERC721Receiver 接口,以确保目标地址可以安全接收 NFT,这通常应用于接收者是智能合约的情况。

在默认的 safeMint 函数中,使用 onlyOwner 修饰符确保了该函数只能由合约所有者调用。使用自增操作 ++,使 _nextTokenId 增加 1,确保下一次铸造时的 tokenId 是唯一的、递增的,从而避免 ID 冲突。同时 setTokenURI(tokenId, uri) 函数确保每个 NFT 都与特定的元数据相关联

数字签名与验证签名的概念

在区块链中,数字签名用于验证交易的真实性和完整性。签名由用户的私钥生成,任何人可以使用用户的公钥进行验证,却无法伪造。验证签名则是通过验证交易的签名是否与交易发送者的公钥匹配,确保交易确实由合法的用户发起且未被篡改。这一过程确保了区块链网络的安全性和信任机制。

简化版流程图

img

1.创建交易:Alice 构建交易数据,包括金额、接收地址等信息或者其他任意信息。

2.生成哈希值:对交易数据进行哈希计算,生成唯一哈希值。

3.签名交易:使用 Alice 的私钥和 ECDSA 算法对哈希值进行签名,生成数字签名。

4.广播交易:将签名后的交易广播到区块链网络。

5.验证交易:矿工 Bob 使用签名和交易数据,恢复用户公钥,并验证与 Alice 的地址是否一致,一致则交易有效。否则,验证失败。

在这个项目中,随着用户学习进度的增加,用户默认的 NFT 也会升级。为了防止用户绕过学习,而直接升级 NFT,因此在升级函数中增加了签名机制:用户达到指定的学习进度后,可以得到平台颁发的签名数据,只能通过该签名数据来升级用户的 NFT,同时,合约中也会验证该签名是否有效

消息哈希

我们使用指定的账户生成了数字签名,那么接下来我们就要在合约中验证这个签名是否有效,是否被恶意篡改。

验证签名的第一步,就是获取原始消息的哈希,我们可以使用 abi.encodePacked 将原始数据 _account、_tokenId 和 _progress 进行编码,然后通过 keccak256 计算哈希值,返回一个 bytes32 类型的哈希。

1keccak256(abi.encodePacked(_account, _tokenId, _progress))

在以太坊中,通常使用签名的消息是一个已签名的以太坊消息哈希,它遵循 EIP-191 标准,即将特定的前缀 "\x19Ethereum Signed Message:\n32" 和上一步的哈希值拼接在一起,再次通过 keccak256 计算生成最终的哈希。这样做的目的是防止交易是可执行的恶意交易,确保验证的安全性。

Syntax

toEthSignedMessageHash

提示

function getMessageHash(address _account, uint256 _tokenId, uint8 _progress) public pure returns(bytes32) {
    return keccak256(abi.encodePacked(_account, _tokenId, _progress));
}

function toEthSignedMessageHash(bytes32 hash) internal pure returns (bytes32) {
	  // 32 is the length in bytes of hash,
	  // enforced by the type signature above
	  return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
}

以太坊中的数字签名采用了 ECDSA(椭圆曲线数字签名算法)方法,该算法对签名数据的长度有限制。为了保证签名验证的效率,通常不会直接对长消息进行签名,而是对该消息的哈希值进行签名。哈希将消息压缩成固定长度的 bytes32 数据,无论消息多长,生成的哈希长度始终一致,这大大简化了签名计算过程。

ECDSA

我们得到了原始数据所对应的以太坊消息哈希(增加了特定前缀),验证签名当然还需要数字签名本身,那么接下来就要用到 OpenZeppelin 的工具库 ECDSA 来进行验证吧。

数字签名通常由三部分组成:r、s 和 v。ECDSA 库中的 tryRecover 函数会从数字签名中解析出这几个部分,并结合以太坊消息哈希,将签名者的地址还原出来。

在 ECDSA.sol 中,tryRecover 函数的实现如下:

function tryRecover(
    bytes32 hash,
    bytes memory signature
) internal pure returns (address recovered, RecoverError err, bytes32 errArg) {
    if (signature.length == 65) {
        bytes32 r;
        bytes32 s;
        uint8 v;
        assembly ("memory-safe") {
            r := mload(add(signature, 0x20))
            s := mload(add(signature, 0x40))
            v := byte(0, mload(add(signature, 0x60)))
        }
        return tryRecover(hash, v, r, s);
    } else {
        return (address(0), RecoverError.InvalidSignatureLength, bytes32(signature.length));
    }
}

在 Solidity 中,assembly 关键字允许使用底层汇编语言直接操作内存和存储数据,从而实现更高效的代码。在这部分中,使用了 mload 指令直接从 signature 内存中加载指定位置的数据。

img

这段汇编代码完成了以下工作:

●计算内存偏移量:在 Solidity 中,数组的第一个 32 字节存储其长度。因此,数组 signature 中的数据真正起始地址位于 signature + 0x20,也就是跳过前 32 字节。

●读取 r 和 s 参数:使用 mload 从签名的特定偏移量加载 32 字节数据,并将其赋值给 r 和 s。

●提取 v 参数:v 参数是一个单字节(1 字节),位于偏移地址 signature + 0x60。在加载完整的 32 字节数据后,再通过 byte(0, mload(...)) 提取第一个有效字节。

升级 NFT 元数据

在我们的项目中,如果用户学习到了指定的阶段,学习平台会给用户下发一个数字签名,用户凭借该数字签名,就可以在合约里升级自己的 NFT(本质上升级的是 NFT 的元数据)。当然,没有数字签名的用户是无法升级自己的 NFT 的。

下面我们会定义一个 updateCourseProgress 函数,它使用用户的 account, tokenId, progress 作为签名的原始数据,并通过前几节介绍的 getMessageHash 函数生成消息哈希,拼接指定前缀(\x19Ethereum Signed Message:\n32)得到以太坊消息哈希,并通过 verify 函数来验证签名是否有效。

在签名校验通过后,我们仍然需要校验 tokenId,确保它是一个有效的值。

首先,我们需要保证这个 tokenId 属于当前的用户,即用户只能升级自己的 NFT,而不能升级别人的。这里我们要用的 ERC721 中的 _ownerOf 函数。

另外,我们也需要确保当前的 tokenId 的课程进度是符合预期的,例如只有初始状态(CourseProgress.INIT)或学习中(CourseProgress.PROGRESS_HALF)的 NFT 可以升级元数据。

终于,我们通过了所有必要的校验,那么接下来我们就可以为符合条件的 NFT 升级元数据了。

这里的逻辑非常简单,progress 代表要升级的课程进度,根据不同的进度,我们调用 _setTokenURI 函数来分配不同的元数据,同时,也会更新状态变量 _courseProgress 中的信息。

由于 CourseProgress 是枚举类型,因此需要使用 uint8 函数来得到枚举对应的数值,才能进行是否相等的判断。

    // SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts ^5.0.0
pragma solidity ^0.8.22;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract HackQuest is ERC721, ERC721URIStorage, Ownable {
    using ECDSA for bytes32;

    string[3] private ipfsUris = [
        "ipfs://QmaWKA1oQ7DMurioZ7fpyJWF8jkZN4w43bKmsYS8hgQQoT",
        "ipfs://QmTsxJitqqqfJYYvEU3q3ogKMadU8mnbtWWwtNg9BkcVXa",
        "ipfs://QmZooiszjSdiGpG2ZUKjrPykHaMjMKRFTigxY2HMVMYdYv"
    ];

    enum CourseProgress {
        INIT,
        PROGRESS_HALF,
        PROGRESS_COMPLETE
    }

    uint256 private _nextTokenId;
    address public _signer;
    mapping(bytes => bool) public _signatures;
    mapping(uint256 => CourseProgress) public _courseProgress;

    constructor(address signer)
        ERC721("HackQuest", "HQ")
        Ownable(msg.sender)
    {
        _signer = signer;
    }


    function safeMint() public {
        uint256 tokenId = _nextTokenId++;
        _safeMint(msg.sender, tokenId);
        _setTokenURI(tokenId, ipfsUris[0]);
        _courseProgress[tokenId] = CourseProgress.INIT;
    }

    function updateCourseProgress(uint256 tokenId, uint8 progress, bytes memory signature) public {
        // verify signature
        require(!_signatures[signature], "Signature Already Used");
        bytes32 _msgHash = getMessageHash(msg.sender, tokenId, progress);
        bytes32 _ethSignedMessageHash = toEthSignedMessageHash(_msgHash);
        require(verify(_ethSignedMessageHash, signature), "Invalid Signature");
        _signatures[signature] = true;

        // verify tokenId
        address from = _ownerOf(tokenId);
        require(from == msg.sender, "Invalid TokenId");

        // verify course progress
        require(_courseProgress[tokenId] == CourseProgress.INIT || _courseProgress[tokenId] == CourseProgress.PROGRESS_HALF, "Course Progress Error");

        if (progress == uint8(CourseProgress.PROGRESS_HALF)) {
            _courseProgress[tokenId] = CourseProgress.PROGRESS_HALF;
            _setTokenURI(tokenId, ipfsUris[1]);
        } else if (progress == uint8(CourseProgress.PROGRESS_COMPLETE)) {
            _courseProgress[tokenId] = CourseProgress.PROGRESS_COMPLETE;
            _setTokenURI(tokenId, ipfsUris[2]);
        }
    }

    
    function getMessageHash(address _account, uint256 _tokenId, uint8 _progress) public pure returns(bytes32) {
        return keccak256(abi.encodePacked(_account, _tokenId, _progress));
    }
    
    function toEthSignedMessageHash(bytes32 hash) internal pure returns (bytes32) {
        // 32 is the length in bytes of hash,
        // enforced by the type signature above
        return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
    }

    function verify(bytes32 _msgHash, bytes memory _signature) public view returns (bool) {
        (address recovered,,) = ECDSA.tryRecover(_msgHash, _signature);
        return recovered == _signer;
    }

    // The following functions are overrides required by Solidity.
    function tokenURI(uint256 tokenId)
        public
        view
        override(ERC721, ERC721URIStorage)
        returns (string memory)
    {
        return super.tokenURI(tokenId);
    }

    function supportsInterface(bytes4 interfaceId)
        public
        view
        override(ERC721, ERC721URIStorage)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}