Arbitrum Stylus 合约实战 :Rust 实现 ERC721
在上一篇中,我们学习了如何在 stylus 使用 rust 编写 ERC20合约,并且部署到了Arbitrum Sepolia ,今天我们继续学习,如何在 stylus 中使用 rust 实现 ERC721 合约,OK, 直接开干!
关于环境准备,请参考上一章节《Arbitrum Stylus 合约实战 :Rust 实现 ERC20》
1. ERC721 标准
下面是 eip erc-721 协议 官方规定必须实现的接口,erc721 我还会出一期 solidity 的教程,会讲到代码以及如何去定义元数据,如何存储到 ipfs上面,以及eip 协议系列博客,这里我们就不展开讲了:
pragma solidity ^0.4.20;/// @title ERC-721 Non-Fungible Token Standard
/// @dev See https://eips.ethereum.org/EIPS/eip-721
/// Note: the ERC-165 identifier for this interface is 0x80ac58cd.
interface ERC721 /* is ERC165 */ {/// @dev This emits when ownership of any NFT changes by any mechanism./// This event emits when NFTs are created (`from` == 0) and destroyed/// (`to` == 0). Exception: during contract creation, any number of NFTs/// may be created and assigned without emitting Transfer. At the time of/// any transfer, the approved address for that NFT (if any) is reset to none.event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId);/// @dev This emits when the approved address for an NFT is changed or/// reaffirmed. The zero address indicates there is no approved address./// When a Transfer event emits, this also indicates that the approved/// address for that NFT (if any) is reset to none.event Approval(address indexed _owner, address indexed _approved, uint256 indexed _tokenId);/// @dev This emits when an operator is enabled or disabled for an owner./// The operator can manage all NFTs of the owner.event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved);/// @notice Count all NFTs assigned to an owner/// @dev NFTs assigned to the zero address are considered invalid, and this/// function throws for queries about the zero address./// @param _owner An address for whom to query the balance/// @return The number of NFTs owned by `_owner`, possibly zerofunction balanceOf(address _owner) external view returns (uint256);/// @notice Find the owner of an NFT/// @dev NFTs assigned to zero address are considered invalid, and queries/// about them do throw./// @param _tokenId The identifier for an NFT/// @return The address of the owner of the NFTfunction ownerOf(uint256 _tokenId) external view returns (address);/// @notice Transfers the ownership of an NFT from one address to another address/// @dev Throws unless `msg.sender` is the current owner, an authorized/// operator, or the approved address for this NFT. Throws if `_from` is/// not the current owner. Throws if `_to` is the zero address. Throws if/// `_tokenId` is not a valid NFT. When transfer is complete, this function/// checks if `_to` is a smart contract (code size > 0). If so, it calls/// `onERC721Received` on `_to` and throws if the return value is not/// `bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"))`./// @param _from The current owner of the NFT/// @param _to The new owner/// @param _tokenId The NFT to transfer/// @param data Additional data with no specified format, sent in call to `_to`function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes data) external payable;/// @notice Transfers the ownership of an NFT from one address to another address/// @dev This works identically to the other function with an extra data parameter,/// except this function just sets data to ""./// @param _from The current owner of the NFT/// @param _to The new owner/// @param _tokenId The NFT to transferfunction safeTransferFrom(address _from, address _to, uint256 _tokenId) external payable;/// @notice Transfer ownership of an NFT -- THE CALLER IS RESPONSIBLE/// TO CONFIRM THAT `_to` IS CAPABLE OF RECEIVING NFTS OR ELSE/// THEY MAY BE PERMANENTLY LOST/// @dev Throws unless `msg.sender` is the current owner, an authorized/// operator, or the approved address for this NFT. Throws if `_from` is/// not the current owner. Throws if `_to` is the zero address. Throws if/// `_tokenId` is not a valid NFT./// @param _from The current owner of the NFT/// @param _to The new owner/// @param _tokenId The NFT to transferfunction transferFrom(address _from, address _to, uint256 _tokenId) external payable;/// @notice Change or reaffirm the approved address for an NFT/// @dev The zero address indicates there is no approved address./// Throws unless `msg.sender` is the current NFT owner, or an authorized/// operator of the current owner./// @param _approved The new approved NFT controller/// @param _tokenId The NFT to approvefunction approve(address _approved, uint256 _tokenId) external payable;/// @notice Enable or disable approval for a third party ("operator") to manage/// all of `msg.sender`'s assets/// @dev Emits the ApprovalForAll event. The contract MUST allow/// multiple operators per owner./// @param _operator Address to add to the set of authorized operators/// @param _approved True if the operator is approved, false to revoke approvalfunction setApprovalForAll(address _operator, bool _approved) external;/// @notice Get the approved address for a single NFT/// @dev Throws if `_tokenId` is not a valid NFT./// @param _tokenId The NFT to find the approved address for/// @return The approved address for this NFT, or the zero address if there is nonefunction getApproved(uint256 _tokenId) external view returns (address);/// @notice Query if an address is an authorized operator for another address/// @param _owner The address that owns the NFTs/// @param _operator The address that acts on behalf of the owner/// @return True if `_operator` is an approved operator for `_owner`, false otherwisefunction isApprovedForAll(address _owner, address _operator) external view returns (bool);
}interface ERC165 {/// @notice Query if a contract implements an interface/// @param interfaceID The interface identifier, as specified in ERC-165/// @dev Interface identification is specified in ERC-165. This function/// uses less than 30,000 gas./// @return `true` if the contract implements `interfaceID` and/// `interfaceID` is not 0xffffffff, `false` otherwisefunction supportsInterface(bytes4 interfaceID) external view returns (bool);
}
2. 创建 stylus 项目
1. 在项目目录下,执行:
cargo stylus new stylus_nft
2. 修改 rust-toolchain.toml 中的版本为 1.81.0,见上图, 修改 Cargo.toml 中的name 为你自定义的name,或者是你的项目名,因为创建项目的时候是拉取的官方的模板,然后main.rs 中的也跟着更改,见下图
3. 在src 目录下新建erc721.rs 文件,并且输入下面内容,重要的行我基本都加上了注释:
use alloc::{string::String, vec, vec::Vec};
use alloy_primitives::{Address, FixedBytes, U256};
use alloy_sol_types::sol;
use core::{borrow::BorrowMut, marker::PhantomData};
use stylus_sdk::{abi::Bytes, evm, msg, prelude::*};// 定义 ERC-721 所需的参数 trait
pub trait Erc721Params {// NFT 的名称,常量const NAME: &'static str;// NFT 的符号,常量const SYMBOL: &'static str;// 获取指定 token_id 的 URIfn token_uri(token_id: U256) -> String;
}// 定义 ERC-721 合约的存储结构
sol_storage! {pub struct Erc721<T: Erc721Params> {// token_id 到拥有者地址的映射mapping(uint256 => address) owners;// 地址到余额的映射mapping(address => uint256) balances;// token_id 到授权用户地址的映射mapping(uint256 => address) token_approvals;// 拥有者地址到操作者地址的授权映射mapping(address => mapping(address => bool)) operator_approvals;// 总供应量uint256 total_supply;// 用于支持 Erc721Params 的 PhantomDataPhantomData<T> phantom;}
}// 定义事件和 Solidity 错误类型
sol! {// 转账事件event Transfer(address indexed from, address indexed to, uint256 indexed token_id);// 授权事件event Approval(address indexed owner, address indexed approved, uint256 indexed token_id);// 批量授权事件event ApprovalForAll(address indexed owner, address indexed operator, bool approved);// token_id 未被铸造或已被销毁error InvalidTokenId(uint256 token_id);// 指定地址不是 token_id 的拥有者error NotOwner(address from, uint256 token_id, address real_owner);// 指定地址无权操作 token_iderror NotApproved(address owner, address spender, uint256 token_id);// 尝试向零地址转账error TransferToZero(uint256 token_id);// 接收者拒绝接收 token_iderror ReceiverRefused(address receiver, uint256 token_id, bytes4 returned);
}// 定义 ERC-721 错误枚举
#[derive(SolidityError)]
pub enum Erc721Error {InvalidTokenId(InvalidTokenId),NotOwner(NotOwner),NotApproved(NotApproved),TransferToZero(TransferToZero),ReceiverRefused(ReceiverRefused),
}// 定义 IERC721TokenReceiver 接口
sol_interface! {// 用于调用实现 IERC721TokenReceiver 的合约的 onERC721Received 方法interface IERC721TokenReceiver {function onERC721Received(address operator, address from, uint256 token_id, bytes data) external returns(bytes4);}
}// 定义 onERC721Received 方法的选择器常量
const ERC721_TOKEN_RECEIVER_ID: u32 = 0x150b7a02;// 实现 ERC-721 内部方法
impl<T: Erc721Params> Erc721<T> {// 检查 msg::sender 是否有权操作指定 tokenfn require_authorized_to_spend(&self,from: Address,token_id: U256,) -> Result<(), Erc721Error> {// 获取 token_id 的拥有者let owner = self.owner_of(token_id)?;// 验证 from 是否为拥有者if from != owner {return Err(Erc721Error::NotOwner(NotOwner {from,token_id,real_owner: owner,}));}// 如果调用者是拥有者,直接返回if msg::sender() == owner {return Ok(());}// 检查调用者是否为拥有者的操作者if self.operator_approvals.getter(owner).get(msg::sender()) {return Ok(());}// 检查调用者是否被授权操作此 tokenif msg::sender() == self.token_approvals.get(token_id) {return Ok(());}// 如果无授权,返回错误Err(Erc721Error::NotApproved(NotApproved {owner,spender: msg::sender(),token_id,}))}// 执行 token 转账操作pub fn transfer(&mut self,token_id: U256,from: Address,to: Address,) -> Result<(), Erc721Error> {// 获取 token_id 的拥有者let mut owner = self.owners.setter(token_id);let previous_owner = owner.get();// 验证 from 是否为拥有者if previous_owner != from {return Err(Erc721Error::NotOwner(NotOwner {from,token_id,real_owner: previous_owner,}));}// 更新 token 的拥有者owner.set(to);// 减少 from 的余额let mut from_balance = self.balances.setter(from);let balance = from_balance.get() - U256::from(1);from_balance.set(balance);// 增加 to 的余额let mut to_balance = self.balances.setter(to);let balance = to_balance.get() + U256::from(1);to_balance.set(balance);// 清除 token 的授权记录self.token_approvals.delete(token_id);// 记录转账事件evm::log(Transfer { from, to, token_id });Ok(())}// 如果接收者是合约,调用 onERC721Received 方法fn call_receiver<S: TopLevelStorage>(storage: &mut S,token_id: U256,from: Address,to: Address,data: Vec<u8>,) -> Result<(), Erc721Error> {// 检查接收者是否为合约if to.has_code() {// 创建接收者接口实例let receiver = IERC721TokenReceiver::new(to);// 调用 onERC721Received 方法let received = receiver.on_erc_721_received(&mut *storage, msg::sender(), from, token_id, data.into()).map_err(|_e| {Erc721Error::ReceiverRefused(ReceiverRefused {receiver: receiver.address,token_id,returned: alloy_primitives::FixedBytes(0_u32.to_be_bytes()),})})?.0;// 验证返回的选择器是否正确if u32::from_be_bytes(received) != ERC721_TOKEN_RECEIVER_ID {return Err(Erc721Error::ReceiverRefused(ReceiverRefused {receiver: receiver.address,token_id,returned: alloy_primitives::FixedBytes(received),}));}}Ok(())}// 执行安全转账并调用 onERC721Receivedpub fn safe_transfer<S: TopLevelStorage + BorrowMut<Self>>(storage: &mut S,token_id: U256,from: Address,to: Address,data: Vec<u8>,) -> Result<(), Erc721Error> {// 执行转账storage.borrow_mut().transfer(token_id, from, to)?;// 调用接收者检查Self::call_receiver(storage, token_id, from, to, data)}// 铸造新 token 并转账给 topub fn mint(&mut self, to: Address) -> Result<(), Erc721Error> {// 获取当前总供应量作为新 token_idlet new_token_id = self.total_supply.get();// 增加总供应量self.total_supply.set(new_token_id + U256::from(1u8));// 执行转账,从零地址到接收者self.transfer(new_token_id, Address::default(), to)?;Ok(())}// 销毁指定 tokenpub fn burn(&mut self, from: Address, token_id: U256) -> Result<(), Erc721Error> {// 执行转账到零地址self.transfer(token_id, from, Address::default())?;Ok(())}
}// 实现 ERC-721 外部方法
#[public]
impl<T: Erc721Params> Erc721<T> {// 获取 NFT 名称pub fn name() -> Result<String, Erc721Error> {Ok(T::NAME.into())}// 获取 NFT 符号pub fn symbol() -> Result<String, Erc721Error> {Ok(T::SYMBOL.into())}// 获取指定 token 的 URI#[selector(name = "tokenURI")]pub fn token_uri(&self, token_id: U256) -> Result<String, Erc721Error> {// 确保 token 存在self.owner_of(token_id)?;Ok(T::token_uri(token_id))}// 获取指定地址的 NFT 余额pub fn balance_of(&self, owner: Address) -> Result<U256, Erc721Error> {Ok(self.balances.get(owner))}// 获取指定 token 的拥有者pub fn owner_of(&self, token_id: U256) -> Result<Address, Erc721Error> {// 获取 token 的拥有者let owner = self.owners.get(token_id);// 如果拥有者是零地址,token 无效if owner.is_zero() {return Err(Erc721Error::InvalidTokenId(InvalidTokenId { token_id }));}Ok(owner)}// 执行带数据的安全转账#[selector(name = "safeTransferFrom")]pub fn safe_transfer_from_with_data<S: TopLevelStorage + BorrowMut<Self>>(storage: &mut S,from: Address,to: Address,token_id: U256,data: Bytes,) -> Result<(), Erc721Error> {// 禁止转账到零地址if to.is_zero() {return Err(Erc721Error::TransferToZero(TransferToZero { token_id }));}// 检查调用者是否有权限storage.borrow_mut().require_authorized_to_spend(from, token_id)?;// 执行安全转账Self::safe_transfer(storage, token_id, from, to, data.0)}// 执行不带数据的安全转账#[selector(name = "safeTransferFrom")]pub fn safe_transfer_from<S: TopLevelStorage + BorrowMut<Self>>(storage: &mut S,from: Address,to: Address,token_id: U256,) -> Result<(), Erc721Error> {// 调用带数据的安全转账,数据为空Self::safe_transfer_from_with_data(storage, from, to, token_id, Bytes(vec![]))}// 执行普通转账pub fn transfer_from(&mut self,from: Address,to: Address,token_id: U256,) -> Result<(), Erc721Error> {// 禁止转账到零地址if to.is_zero() {return Err(Erc721Error::TransferToZero(TransferToZero { token_id }));}// 检查调用者是否有权限self.require_authorized_to_spend(from, token_id)?;// 执行转账self.transfer(token_id, from, to)?;Ok(())}// 为指定 token 设置授权pub fn approve(&mut self, approved: Address, token_id: U256) -> Result<(), Erc721Error> {// 获取 token 的拥有者let owner = self.owner_of(token_id)?;// 验证调用者是否有权限if msg::sender() != owner && !self.operator_approvals.getter(owner).get(msg::sender()) {return Err(Erc721Error::NotApproved(NotApproved {owner,spender: msg::sender(),token_id,}));}// 设置授权self.token_approvals.insert(token_id, approved);// 记录授权事件evm::log(Approval {approved,owner,token_id,});Ok(())}// 设置批量授权pub fn set_approval_for_all(&mut self,operator: Address,approved: bool,) -> Result<(), Erc721Error> {// 获取调用者地址let owner = msg::sender();// 设置操作者授权self.operator_approvals.setter(owner).insert(operator, approved);// 记录批量授权事件evm::log(ApprovalForAll {owner,operator,approved,});Ok(())}// 获取指定 token 的授权地址pub fn get_approved(&mut self, token_id: U256) -> Result<Address, Erc721Error> {Ok(self.token_approvals.get(token_id))}// 检查是否为所有者设置了操作者授权pub fn is_approved_for_all(&mut self,owner: Address,operator: Address,) -> Result<bool, Erc721Error> {Ok(self.operator_approvals.getter(owner).get(operator))}// 检查是否支持指定接口pub fn supports_interface(interface: FixedBytes<4>) -> Result<bool, Erc721Error> {// 将接口 ID 转换为字节数组let interface_slice_array: [u8; 4] = interface.as_slice().try_into().unwrap();// 特殊处理 ERC165 标准中的 0xffffffffif u32::from_be_bytes(interface_slice_array) == 0xffffffff {return Ok(false);}// 定义支持的接口 IDconst IERC165: u32 = 0x01ffc9a7;const IERC721: u32 = 0x80ac58cd;const IERC721_METADATA: u32 = 0x5b5e139f;// 检查是否支持指定接口Ok(matches!(u32::from_be_bytes(interface_slice_array),IERC165 | IERC721 | IERC721_METADATA))}
}
3. 在src 目录下的 lib.rs 文件中输入下面内容,注意,token_uri ,需要自己去 ipfs 上面存储 json 元数据,然后复制链接复制给token_uri:
// 如果未启用 export-abi 特性,仅作为 WASM 运行
#![cfg_attr(not(any(feature = "export-abi", test)), no_main)]
extern crate alloc;// 引入模块和依赖
mod erc721;use crate::erc721::{Erc721, Erc721Error, Erc721Params};
use alloy_primitives::{Address, U256};
// 引入 Stylus SDK 和 alloy 基本类型
use stylus_sdk::{msg, prelude::*};// 定义 NFT 参数结构体
struct StylusNFTParams;
// 实现 Erc721Params trait
impl Erc721Params for StylusNFTParams {// 定义 NFT 名称常量const NAME: &'static str = "DOG";// 定义 NFT 符号常量const SYMBOL: &'static str = "DOG";// 生成指定 token_id 的 URIfn token_uri(token_id: U256) -> String {format!("{}{}{}", "https://external-magenta-alpaca.myfilebase.com/ipfs/QmY47C6mUFEGPGF5muGTEcSD3MPspCSpT2EGJV8QvQGUnV", token_id, ".json")}
}// 定义合约入口点和存储结构
sol_storage! {#[entrypoint]struct StylusNFT {// 允许 erc721 访问 StylusNFT 的存储并调用方法#[borrow]Erc721<StylusNFTParams> erc721;}
}// 实现 StylusNFT 的外部方法
#[public]
#[inherit(Erc721<StylusNFTParams>)]
impl StylusNFT {// 铸造 NFT 给调用者pub fn mint(&mut self) -> Result<(), Erc721Error> {// 获取调用者地址let minter = msg::sender();// 调用 erc721 的 mint 方法self.erc721.mint(minter)?;Ok(())}// 铸造 NFT 给指定地址pub fn mint_to(&mut self, to: Address) -> Result<(), Erc721Error> {// 调用 erc721 的 mint 方法self.erc721.mint(to)?;Ok(())}// 销毁指定 NFTpub fn burn(&mut self, token_id: U256) -> Result<(), Erc721Error> {// 调用 erc721 的 burn 方法,验证调用者是否拥有 tokenself.erc721.burn(msg::sender(), token_id)?;Ok(())}// 获取总供应量pub fn total_supply(&mut self) -> Result<U256, Erc721Error> {// 获取 erc721 的总供应量Ok(self.erc721.total_supply.get())}
}
4. 后续如果有什么编译报错,请参考我的配置文件,因为可能你创建项目的时候,版本更新了:
[package]
name = "stylus_erc721_example"
version = "0.1.11"
edition = "2021"
license = "MIT OR Apache-2.0"
homepage = "https://github.com/OffchainLabs/stylus-hello-world"
repository = "https://github.com/OffchainLabs/stylus-hello-world"
keywords = ["arbitrum", "ethereum", "stylus", "alloy"]
description = "stylus erc721 example"[dependencies]
alloy-primitives = "=0.8.20"
alloy-sol-types = "=0.8.20"
mini-alloc = "0.9.0"
stylus-sdk = "0.9.0"
hex = "0.4.3"
dotenv = "0.15.0"[dev-dependencies]
alloy-primitives = { version = "=0.8.20", features = ["sha3-keccak"] }
tokio = { version = "1.12.0", features = ["full"] }
ethers = "2.0"
eyre = "0.6.8"
stylus-sdk = { version = "0.9.0", features = ["stylus-test"] }[features]
export-abi = ["stylus-sdk/export-abi"]
debug = ["stylus-sdk/debug"][[bin]]
name = "stylus_erc721_example"
path = "src/main.rs"[lib]
crate-type = ["lib", "cdylib"][profile.release]
codegen-units = 1
strip = true
lto = true
panic = "abort"# If you need to reduce the binary size, it is advisable to try other
# optimization levels, such as "s" and "z"
opt-level = 3
3. 编译与链上验证
1. 编译与链上验证,执行 :
cargo stylus check -e https://sepolia-rollup.arbitrum.io/rpc
2. 估算部署合约所需的 gas,依次执行:
export ARB_RPC_URL=https://sepolia-rollup.arbitrum.io/rpc
export PRIVATE_KEY=你的私钥
cargo stylus deploy --endpoint=$ARB_RPC_URL --private-key=$PRIVATE_KEY --estimate-gas
3. 链上部署
执行部署命令:
cargo stylus deploy --endpoint=$ARB_RPC_URL --private-key=$PRIVATE_KEY
可以看到这里我们部署成功,合约地址与 hash:
deployed code at address: 0xb326ed79eb80f475f2aba0df0edeabc54b5a07b8
deployment tx hash: 0xf73b6bff3ed6253d2420226eccb54b26f7e37e5f37c55df60b62f3c1a19e995b
4. 执行交互
我们使用 foundry cast 来进行交互,先把所需的环境变量导出,这样方便操作:
export NFT=你部署的NFT合约地址
export USER=你的钱包地址
1. 检查 NFT 合约的账户余额和所有者
cast call --rpc-url $ARB_RPC_URL $NFT "balanceOf(address) (uint256)" $USER
这个时候刚部署合约,没有余额,所以结果是0
2. 检查对应 NFT 的所有者,我们这里查看 token_id = 0 的所有者
cast call --rpc-url $ARB_RPC_URL $NFT "ownerOf(uint256) (address)" 0
可以看到这里报错了,因为我们还没有mint 任何的NFT, 在代码里面有判断,如果找不到对应的所有者,将返回一个错误
3. 我们来给自己 mint 一个NFT
OK mint 一个 NFT 成功!
4. 我们再来验证一下前面的步骤:
可以看到,余额已经变成了1,并且 token_id = 0 的所有者也输出了,在浏览器也能看到地址已经成功mint了一个NFT。 到这里就完成了一个完整的erc721的合约,从代码到部署,再到验证,如果你也跟我一样走到了最后,那么恭喜你,给自己一点鼓励吧,你是最棒的!
5. 总结
代码仓库:stylus_nft
今天我们学习了如何在 stylus 中编写erc721合约,也学会了如何去部署已经验证,希望大家多练习,多思考,重复巩固知识,思考推动进步,好了,今天就到这里啦,我是红烧6,关注我(主页有v),带你遨游web3