solidity进阶(更新中)
开启第二阶段,主要学习合约部署、测试和预言机。
CryptoZombies的教程是用Truffle,现在主流是Hardhat,但学一学思想也有益无害。
----------------------------
update 5.3 学完了Truffle部署合约,后面几节是部署到它们的Loom网络,就不写这几节的笔记了
- 启动一个新的终端窗口,创建项目目录并
cd
进入该目录,运行npm install truffle -g
安装 Truffle 并使其全局可用。安装Truffle后,运行truffle init
来初始化我们的新项目。它创建一组具有以下结构的文件夹和配置文件:
├── contracts
├── Migrations.sol
├── migrations
├── 1_initial_migration.js
└── test
truffle-config.js
truffle.js
- contracts 是 Truffle 期望找到我们所有智能合约的地方。为了保持代码的组织性,我们还可以创建嵌套文件夹,例如
contracts/tokens
。truffle init
会自动创建一个名为Migrations.sol
的合约以及相应的迁移文件,用于跟踪您对代码所做的更改,它的工作方式是将更改历史记录保存在链上。因此,您不可能将相同的代码部署两次。 - migrations 目录下的一个 migration 就是一个 JavaScript 文件,告诉 Truffle 如何部署智能合约。
- test 目录放置单元测试,可以是JavaScript 或 Solidity 文件。合约一旦部署就无法更改,因此我们必须在部署智能合约之前对其进行测试。
- truffle.js 和 truffle-config.js 是配置文件,用于存储部署的网络设置。 Truffle 需要两个配置文件,因为在Windows上将
truffle.js
和truffle.exe
放在同一个文件夹中可能会导致冲突。如果您运行的是 Windows,建议删除truffle.js
并使用truffle-config.js
作为默认配置文件。 - 我们将使用 Infura 将代码部署到以太坊,但是Infura 不管理私钥,也就是它无法代表我们签署交易。由于部署智能合约需要 Truffle 签署交易,我们需要一个名为
truffle-hdwallet-provider
的工具,它的唯一目的是处理交易签名。由于truffle init
命令期望找到一个空目录,我们在运行truffle init
之后安装truffle-hdwallet-provider
。 - 以太坊虚拟机无法直接理解我们编写的 Solidity 源代码,编译器将我们的智能合约“翻译”为机器可读的字节码,然后虚拟机执行字节码,并完成我们的智能合约所需的操作。我们将游戏项目的所有智能合约复制到
./contracts
文件夹中,执行truffle compile
进行编译。此命令应创建构建工件(artifacts)并将它们放置在./build/contracts
目录中。构建工件由智能合约的“字节码”版本、ABI 以及 Truffle 用于正确部署代码的一些内部数据组成。避免编辑这些文件,否则 Truffle 可能会停止正常工作。 - 如果要部署多个合约,则必须为每个合约创建单独的migration文件,Migrations 始终按顺序执行 - 1、2、3 等。从创建好的
./contracts/1_initial_migration.js
开始,首先脚本告诉 Truffle 我们想要与Migrations
合约进行交互,然后导出一个函数,该函数接受deployer
对象作为参数,该对象充当您(开发人员)和 Truffle 部署引擎之间的接口。我们创建一个新的迁移文件./contracts/2_crypto_zombies.js
来部署我们自己的合约。
var CryptoZombies = artifacts.require("./CryptoZombies.sol");
module.exports = function(deployer) {
deployer.deploy(CryptoZombies);
};
- 有几个公共以太坊测试网可让您在将合约部署到主网之前免费测试您的合约(请记住,一旦将合约部署到主网,就无法更改)。这些测试网络使用与主网不同的共识算法(通常是 PoA),并且以太币是免费的。我们将使用 Rinkeby,由以太坊基金会创建的公共测试网络。在部署之前,我们必须编辑配置文件来告诉 Truffle 我们想要部署到的网络。正常情况下,为了避免泄露您的助记词(或您的私钥),您应该从文件中读取它并将该文件添加到
.gitignore
。此处仅为演示方便。
// Initialize HDWalletProvider
const HDWalletProvider = require("truffle-hdwallet-provider");
// Set your own mnemonic here
const mnemonic = "YOUR_MNEMONIC";
// Module exports to make this configuration available to Truffle itself
module.exports = {
// Object with configuration for each network
networks: {
// Configuration for mainnet
mainnet: {
provider: function () {
// Setting the provider with the Infura Mainnet address and Token
return new HDWalletProvider(mnemonic, "https://mainnet.infura.io/v3/YOUR_TOKEN")
},
network_id: "1"
},
// Configuration for rinkeby network
rinkeby: {
// Special function to setup the provider
provider: function () {
// Setting the provider with the Infura Rinkeby address and Token
return new HDWalletProvider(mnemonic, "https://rinkeby.infura.io/v3/YOUR_TOKEN")
},
// Network id is 4 for Rinkeby
network_id: 4
}
}
};
- 在进行部署之前,请确保您的帐户中有足够的以太币。获取以太币用于测试目的的最简单方法是通过名为
faucet
的服务。我们推荐在 Rinkeby 上运行的Authenticated Faucet。在终端中运行truffle migrate --network rinkeby
以把合约部署到 Rinkeby。如果是部署到主网,在测试合约后运行truffle migrate --network mainnet
。 - 为了防止私钥文件被推送到 GitHub,我们创建一个名为
.gitignore
的新文件,然后通过以下命令告诉GitHub,我们希望它忽略保存私钥的文件。我们还需要编辑truffle.js
配置文件,定义一个从文件中读取私钥并初始化新的HDWalletProvider
的函数。
touch .gitignore
echo mainnet_private_key >> .gitignore
function getProviderWithPrivateKey (privateKeyPath, chainId, writeUrl, readUrl) {
const privateKey = readFileSync(privateKeyPath, 'utf-8');
return new HDWalletProvider(chainId, writeUrl, readUrl, privateKey);
}
----------------------------
update 5.18 有一阵子没学合约相关了,今天把测试的部分给看完了
update 5.19 补了笔记,编写测试时 async 和 await 要注意
- 回顾一下,我们的文件结构应当如下所示,其中
test
文件夹就是我们要放测试文件的地方。最佳实践是为每个合约创建一个单独的测试文件,并为其指定智能合约的名称。
├── build
├── contracts
├── Migrations.json
├── CryptoZombies.json
├── erc721.json
├── ownable.json
├── safemath.json
├── zombieattack.json
├── zombiefactory.json
├── zombiefeeding.json
├── zombiehelper.json
├── zombieownership.json
├── contracts
├── Migrations.sol
├── CryptoZombies.sol
├── erc721.sol
├── ownable.sol
├── safemath.sol
├── zombieattack.sol
├── zombiefactory.sol
├── zombiefeeding.sol
├── zombiehelper.sol
├── zombieownership.sol
├── migrations
└── test
├── CryptoZombies.js
. package-lock.json
. truffle-config.js
. truffle.js
- 每次编译智能合约时,Solidity 编译器都会生成一个 JSON 文件(称为构建工件),其中包含该合约的二进制表示形式,并将其保存在
build/contracts
文件夹中。当运行migration文件时,Truffle 会使用与该网络相关的信息更新此文件。每次编写新的测试时,您需要加载想要与之交互的合约的构建工件。该函数返回合约抽象(contract abstraction),合约抽象隐藏了与以太坊交互的复杂性,并为我们的 Solidity 合约提供了方便的 JavaScript 接口。
const CryptoZombies = artifacts.require("CryptoZombies");
- 我们可以调用
contract()
函数对测试分组,它通过提供用于测试的帐户列表并进行一些清理来扩展 Mocha 的describe()
。它有两个参数,第一个string
参数指示我们要测试的内容,第二个参数callback
是我们实际编写测试的地方。我们执行测试的方法是调用it()
函数,它也有两个参数:一个string
描述测试实际执行的操作,以及一个callback
。第二个参数(callback
函数)将与区块链“对话”,这意味着该函数是异步的,需添加async
关键字。这样,每次使用await
关键字调用该函数时,我们的测试都会等待它返回。
contract("CryptoZombies", (accounts) => {
it("should be able to create a new zombie", async () => {
})
})
- 可以用 Ganache 设置本地以太坊网络,在本地测试您的智能合约。每次 Ganache 启动时,它都会创建 10 个测试帐户,并为他们提供 100 个以太币。我们可以通过前面提到的
accounts
数组访问这些帐户。为了帮助理解,我们想使用两个占位符名称 - Alice 和 Bob。在contract()
函数中,我们像这样初始化它们:
let [alice, bob] = accounts;
- 通常,每个测试都有以下阶段:1-设置,我们在其中定义初始状态并初始化输入;2-行动,我们实际测试代码的地方,始终确保只测试一件事;3-断言,我们检查结果的地方。
- 在设置阶段,为了与我们的合约进行实际交互,我们必须创建一个 JavaScript 对象来充当合约的实例。我们用合约抽象来初始化我们的实例。
const contractInstance = await CryptoZombies.new();
- 在行动阶段,我们可以用实例调用合约的方法。但是我们怎样才能让方法“知道”谁调用了它?Truffle 包装了原始的 Solidity 实现,并允许将地址作为参数传递来指定进行函数调用的地址。
const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice});
- 一旦我们使用
artifacts.require
指定了想要测试的合约,Truffle 就会自动提供我们的智能合约生成的日志,这意味着我们现在可以使用result.logs[0].args.name
检索 Alice 新创建的僵尸的名字。以类似的方式,我们还可以获得交易相关的其他信息:result.tx
是交易哈希;result.receipt
是一个包含交易收据的对象。result.receipt.status
为true
表示交易成功,否则说明交易失败。日志也可以用作存储数据的更便宜的选择,缺点是无法从合约内部访问它们。 - 断言阶段我们使用内置断言函数,如
equal()
和deepEqual()
。这些函数检查条件,如果结果不符合预期, 就throw
错误。比较简单的值时运行assert.equal()
。
assert.equal(result.receipt.status, true);
assert.equal(result.logs[0].args.name,zombieNames[0]);
- Mocha(和 Truffle)的功能之一是能够在测试前后运行一些称为 hooks 的代码片段。要在执行测试之前运行某些内容,应将代码放入名为
beforeEach()
的函数中。因此,我们不必为每个测试都创建一个新的合约实例,只需执行一次:
let contractInstance;
beforeEach(async () => {
contractInstance = await CryptoZombies.new();
});
- 一旦不再需要,我们的合同就应该
selfdestruct
。首先,向CryptoZombies
智能合约添加一个新函数;接下来,我们将在测试文件中创建一个名为afterEach()
的函数;最后,Truffle 将确保在执行测试后调用此函数。
function kill() public onlyOwner {
selfdestruct(owner());
}
afterEach(async () => {
await contractInstance.kill();
});
- 现在进行第二个测试,当alice尝试创建第二个僵尸时,我们预期合约会抛出错误。由于只有当合约出错时测试才会通过,因此必须将第二个
createRandomZombie
函数调用包装在try/catch
块内。为了保持测试整洁,将这段代码移动到test/helpers/utils.js
并将其导入到测试文件中。
async function shouldThrow(promise) {
try {
await promise;
assert(true);
}
catch (err) {
return;
}
assert(false, "The contract did not throw.");
}
module.exports = {
shouldThrow,
};
const utils = require("./helpers/utils");
it("should not allow two zombies", async () => {
await contractInstance.createRandomZombie(zombieNames[0], {from: alice});
await utils.shouldThrow(contractInstance.createRandomZombie(zombieNames[1], {from: alice}));
})
- 我们还想测试一下 Alice 把自己的僵尸转移给 Bob 的功能。我们的僵尸继承自 ERC721,ERC721规范有两种不同的代币转移方式: 第一种方式是 Alice(所有者)调用
transferFrom
,其中她的address
作为_from
参数,Bob 的address
作为_to
参数,以及她想要传输的zombieId
。第二种方式是 Alice 首先使用 Bob 的地址和zombieId
调用approve
,然后合约存储 Bob 被批准拿走僵尸。接下来,当 Alice 或 Bob 调用transferFrom
时,合约会检查msg.sender
是否等于 Alice 或 Bob 的地址,如果是,它将僵尸转移给 Bob。 - 尽管逻辑简单,但第二种情况需要至少两次测试:Alice 本人是否能转移僵尸,以及 Bob 能否转移僵尸。建立一个可扩展的结构能够更清楚地理解代码。为了对测试进行分组,Truffle 提供了一个名为
context
的函数。如果想要跳过测试,只要加个x
就可以:xcontext()
,xit()
。当编写了这些函数的测试后,不要忘记删除所有的 x。
xcontext("with the single-step transfer scenario", async () => {
it("should transfer a zombie", async () => {
// TODO: Test the single-step transfer scenario.
})
})
xcontext("with the two-step transfer scenario", async () => {
it("should approve and then transfer a zombie when the approved address calls transferFrom", async () => {
// TODO: Test the two-step scenario. The approved address calls transferFrom
})
it("should approve and then transfer a zombie when the owner calls transferFrom", async () => {
// TODO: Test the two-step scenario. The owner calls transferFrom
})
})
- 第一种情况的测试逻辑如下:Alice 创建一个新的僵尸;让 Alice 把她的僵尸转移给 Bob;此时 Bob 应当拥有自己的僵尸,
ownerOf
将返回一个等于 Bob 地址的值。我们用断言来检查这一点。
context("with the single-step transfer scenario", async () => {
it("should transfer a zombie", async () => {
const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice});
const zombieId = result.logs[0].args.zombieId.toNumber();
await contractInstance.transferFrom(alice, bob, zombieId, {from: alice});
const newOwner = await contractInstance.ownerOf(zombieId);
assert.equal(newOwner, bob);
})
})
- 第二种情况需要测试两个不同场景,区别在于是谁调用了转移函数。步骤如下:Alice 创建一个新的僵尸,然后调用
approve
;接下来,Bob 调用transferFrom
,成为僵尸的所有者。最后用断言检查。
context("with the two-step transfer scenario", async () => {
it("should approve and then transfer a zombie when the approved address calls transferFrom", async () => {
const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice});
const zombieId = result.logs[0].args.zombieId.toNumber();
await contractInstance.approve(bob, zombieId, {from: alice});
await contractInstance.transferFrom(alice, bob, zombieId, {from: bob});
const newOwner = await contractInstance.ownerOf(zombieId);
assert.equal(newOwner,bob);
})
//...
})
- 最后测试僵尸攻击的功能。游戏中有冷却时间的设置,我们可以用 Ganache 的两个辅助函数
evm_increaseTime
和evm_mine
来“时间旅行”。 每次开采新区块时,矿工都会为其添加时间戳。假设创建僵尸的交易是在区块 5 中开采的。接下来,我们调用evm_increaseTime
,由于区块链是不可变的,现有块无法被修改,当合约检查时间的时候,时间不会增加。如果运行evm_mine
,第 6 号区块将被开采(并带有时间戳)。把它们放在一起,就可以实现“时间旅行”了。(显然,时间旅行在主网上或任何由矿工保护的测试链上都不可用)将此逻辑移动到名为helpers/time.js
的新文件中。
async function increase(duration) {
//first, let's increase time
await web3.currentProvider.sendAsync({
jsonrpc: "2.0",
method: "evm_increaseTime",
params: [duration], // there are 86400 seconds in a day
id: new Date().getTime()
}, () => {});
//next, let's mine a new block
web3.currentProvider.send({
jsonrpc: '2.0',
method: 'evm_mine',
params: [],
id: new Date().getTime()
})
}
const duration = {
seconds: function (val) {
return val;
},
minutes: function (val) {
return val * this.seconds(60);
},
hours: function (val) {
return val * this.minutes(60);
},
days: function (val) {
return val * this.hours(24);
},
}
module.exports = {
increase,
duration,
};
const time = require("./helpers/time");
it("zombies should be able to attack another zombie", async () => {
let result;
result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice});
const firstZombieId = result.logs[0].args.zombieId.toNumber();
result = await contractInstance.createRandomZombie(zombieNames[1], {from: bob});
const secondZombieId = result.logs[0].args.zombieId.toNumber();
await time.increase(time.duration.days(1));
await contractInstance.attack(firstZombieId, secondZombieId, {from: alice});
assert.equal(result.receipt.status, true);
})
- Chai 有三种断言样式,expect,should 和 assert:
let lessonTitle = "Testing Smart Contracts with Truffle";
expect(lessonTitle).to.be.a("string");
let lessonTitle = "Testing Smart Contracts with Truffle";
lessonTitle.should.be.a("string");
let lessonTitle = "Testing Smart Contracts with Truffle";
assert.typeOf(lessonTitle, "string");
- 为了使用
expect
样式,我们要将其导入到我们的项目中。我们可以使用expect
来测试交易是否成功,或检查 Alice 是否拥有僵尸,如下所示:
var expect = require('chai').expect;
expect(result.receipt.status).to.equal(true);
expect(zombieOwner).to.equal(alice);
----------------------------
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧