Discovering OpenZeppelin Defender features with an NFT Game
Mark Kop
Posted on May 10, 2022
OpenZeppelin Defender is a web-based application that allows developers to perform and automate smart contract operations securely.
This blog post will show how some of the Defender features can be useful for interacting and securing a simple NFT Game.
In this game, users will obtain Hero NFTs by passing a captcha challenge. The NFT can then be used as voting power to propose the creation of a new guild in the game.
The system will attack these guilds from time to time, and their members will be able to repair the damages.
The code used in this project is available in the repository below.
Markkop / guilds-openzeppelin-defender
A Web3 demo game that uses OpenZeppelin's Defender features
๐ฎ NFT Contract
We start by creating the NFT contract with minting and voting power. Here, another OpenZeppelin tool becomes really useful. The Wizard can generate the contract we want with just a few clicks.
However, we will need to change it to add an anti-bot measurement.
๐ค Anti-bot NFT Minting
By using two Defender features, Relayer and Autotask, we can protect the NFT minting function behind a captcha challenge.
This idea and its original implementation were made by tinchoabbate on the OpenZeppelin's Forum.
Here we will be replicating his code and changing just a few lines so we can move to other features.
If you would like to understand the details of this implementation better, make sure to check the link above and the repository below.
It starts by modifying the NFT contract safeMint
function and creating a getCurrentTokenId
method in the Autotask script.
...
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/draft-EIP712.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/draft-ERC721Votes.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract Hero is ERC721, Ownable, EIP712, ERC721Votes, ReentrancyGuard {
using Counters for Counters.Counter;
// Address of OZ Defender's Relayer
address private immutable _defender;
Counters.Counter private _tokenIdCounter;
constructor(address defender) ERC721("Hero", "HERO") EIP712("Hero", "1") {
require(defender != address(0));
_defender = defender;
}
function safeMint(
address to,
bytes32 hash,
bytes memory signature
)
nonReentrant
public
{
uint256 tokenId = _tokenIdCounter.current();
require(
hash == keccak256(abi.encode(msg.sender, tokenId, address(this))),
"Invalid hash"
);
require(
ECDSA.recover(ECDSA.toEthSignedMessageHash(hash), signature) == _defender,
"Invalid signature"
);
_tokenIdCounter.increment();
_safeMint(to, tokenId);
}
function getCurrentTokenId() public view returns (uint256){
return _tokenIdCounter.current();
}
// The following functions are overrides required by Solidity.
function _afterTokenTransfer(address from, address to, uint256 tokenId)
internal
override(ERC721, ERC721Votes)
{
super._afterTokenTransfer(from, to, tokenId);
}
}
Now we deploy the contract on Rinkeby, and using the NFT contract's address, we create a Defender Relayer by going to this page.
Then we create an Autotask script that will be triggered by a webhook post action.
The script is the same one created by tinchoabbate with a small contract abi change and can be seen here.
Finally, we build a front end with a captcha feature with NextJS and hCaptcha.
Here I cloned the existing app built on the nft-minter-for-humans project to make sure that the whole process was working.
The important is to look there to check how the Autotask webhook URL is being integrated with the captcha verification.
๐ฐ Guilds Contract
Let's now see how the Admin feature can help manage a governor contract.
We start by developing the Guilds contract, responsible for creating and storing guilds data.
//SPDX-License-Identifier: Unlicense
import "hardhat/console.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
contract Guilds is Ownable {
using Counters for Counters.Counter;
Counters.Counter public _guildIds;
mapping(uint256 => Guild) public guildIdToGuild;
struct Guild {
uint256 guildId;
bytes32 name;
address[] members;
}
constructor() {
}
function createGuild(bytes32 name, address[] memory members) public onlyOwner {
_guildIds.increment();
uint256 guildId = _guildIds.current();
guildIdToGuild[guildId] = Guild(
guildId,
name,
members
);
}
function getGuildMembers(uint256 guildId) public view returns (address[] memory) {
return guildIdToGuild[guildId].members;
}
}
Note that we're using two OpenZeppelin contracts: Ownable and Counters.
Counters will help us to create guild ids, while Ownable is required to transfer ownership to the GuildsGovernor contract we'll be making next.
๐๏ธ GuildsGovernor Contract
With OpenZeppelin Wizard, we can again easily create a governor contract.
With this contract deployed, we can now call transferOwnership
from the Guilds contract with GuildsGovernor address to make it its new owner.
Now we can use OpenZeppelin Defender to propose a new guild using the propose
method.
The tricky part here is to provide arguments for this function. If you want to learn more about OpenZeppelin Governance Contract, take a look at this documentation.
The target
address is the contract we want the governor's contract to interact with.
In the value
field, we can input how much ETH we want to transfer.
Finally, calldata
is the target encoded function with the parameters we want to send.
This calldata
value can be obtained in two ways:
๐ง Using Remix
- Open Remix and create a new file pasting the Guilds code in it.
- Deploy the contract in any environment.
- Convert a guild name string to hex string.
- Expand the
createGuild
method, provide the converted guild name (with0x
), an array with any address and copy the encoded call data.
โ๏ธ Using EthersJS
- Create a Guilds contract instance by deploying or attaching it.
- Convert the guild name to a hex string using the code below.
- Call the guilds contract interface with
encodeFunctionData
, passing the arguments, and copy the result.
const guildName = ethers.utils.hexZeroPad(
ethers.utils.hexlify(ethers.utils.toUtf8Bytes("MyGuild")),
32
);
const encodedFunctionCall = guilds.interface.encodeFunctionData(
"createGuild",
[guildName, [user.address]]
);
console.log(encodedFunctionCall);
Now that we have the calldata
value add it alongside the other parameters like in the screenshot above and run and approve the admin action with EOA (Externally Owned Account) like Metamask.
Voilรก! We just used Defender Admin to interact with a Governor contract and propose an action.
The idea, of course, is allowing any holder to propose a guild creation. Having them set up a Defender's Admin action is not practical.
We did it only as an example. Ideally, a custom frontend would be built on top of the contract for proposing and voting. Tools like Tally can be used as well.
๐ Auto Attack a Guild
We've seen how to use Autotask by webhook triggers. Now let's try running it on a scheduled basis.
First, we have to change the Guild contract to let guilds have life values.
Then we create an attackGuild
function to reduce a guild's life by one. This function can only be called by our relayer, so we will use OpenZeppelin's AccessControl contract.
This is what the modified Guilds contract should look like:
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.4;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
contract Guilds is Ownable, AccessControl {
using Counters for Counters.Counter;
bytes32 public constant AUTO_ATTACKER_ROLE = keccak256("AUTO_ATTACKER_ROLE");
Counters.Counter public _guildIds;
mapping(uint256 => Guild) public guildIdToGuild;
struct Guild {
uint256 guildId;
bytes32 name;
address[] members;
uint256 maxLife;
uint256 currentLife;
}
constructor(address autoAttackerAddress) {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(AUTO_ATTACKER_ROLE, autoAttackerAddress);
}
function createGuild(bytes32 name, address[] memory members) public onlyOwner {
_guildIds.increment();
uint256 guildId = _guildIds.current();
guildIdToGuild[guildId] = Guild(
guildId,
name,
members,
10,
10
);
}
function getGuildMembers(uint256 guildId) public view returns (address[] memory) {
return guildIdToGuild[guildId].members;
}
function attackGuild(uint256 guildId) public onlyRole(AUTO_ATTACKER_ROLE) {
Guild storage guild = guildIdToGuild[guildId];
require(guild.currentLife > 0, "Guild is dead");
guild.currentLife -= 1;
}
function getGuildLife(uint256 guildId) public view returns (uint256) {
return guildIdToGuild[guildId].currentLife;
}
}
Note that now when deploying this contract, we need to provide the relayer address. We can use the one we have already created, but here I will be making a new one.
This time we need to provide it funds to perform the attackGuild
transaction. For this, we can simply transfer ether to its address.
Now we can create a new Autotask and attach it to the relayer. Let's set it up to run every minute just for testing, but we would change it for something like days or weeks.
The script we're using is the following one.
const { ethers } = require("ethers");
const {
DefenderRelayProvider,
DefenderRelaySigner
} = require('defender-relay-client/lib/ethers');
const contractAbi = ["function attackGuild(uint256 guildId) public"];
exports.handler = async function(event) {
const { guildsContractAddress } = event.secrets;
const provider = new DefenderRelayProvider(event);
const signer = new DefenderRelaySigner(event, provider, { speed: 'fast' });
const guildsContract = new ethers.Contract(guildsContractAddress, contractAbi, signer);
const attackTx = await guildsContract.attackGuild(1);
console.log(attackTx);
return attackTx.hash;
}
After creating the autotask, go back to the Autotask Dashboard, click on "Secrets" and add the key guildsContractAddress
with the value of the guilds' contract address.
Now you will see attackGuild
transactions appearing in the block explorer.
๐ That's it!
At this point, you might have noticed that this is not a game yet. Holders can't heal their guilds, and nothing even happens when guilds reach zero life.
Other implementations still should be finished up, like adding/removing guild members, initializing the guild members with the ones that vote for its creation, and handling vote power for owners with multiple NFTs.
The goal of this blog post was to show some of the OpenZeppelin Defender features in a made-up context, and I think we can wrap things here.
Defender indeed can prove to be valuable or even indispensable for more significant and more relevant projects.
I can't wait to see what else will be added to the application.
Posted on May 10, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.