Web3 Unleashed: Write a Rentable NFT Smart Contract¶
Written by Emily Lin and Leandro Faria
Last updated 11/15/2022
Overview¶
In this guide, we'll be covering what the ERC-4907 rentable NFT standard is and how we can implement one using Truffle!
Watch our livestream recording with Jesse Luong from Double Protocol, the creators of the ERC-4907 standard on YouTube for a more in-depth explanation and exploration into the standard's impact on GameFi and the metaverse!
What is the ERC-4907?¶
NFT renting has become a growing use case for utility based NFTs - for example, virtual land in the metaverse or in-game NFT assets. In the first episode of Web3 Unleashed, we learned that ERCs are application level standards that establish a shared interface for contracts and dapps to reliably interact with each other. In this case, ERC-4907 standardizes the way NFT rentals happen by separating the concept of user
and owner
. This allows us to identify permissioned roles on the NFT. That is, a user
has the ability to use the NFT, but does not have the permission to sell it. In addition, an expires
function is introduced, so that the user
only has temporary access to use the NFT.
What's in an ERC-4907?¶
The interface is specified as follows:
interface IERC4907 {
// Logged when the user of a NFT is changed or expires is changed
/// @notice Emitted when the `user` of an NFT or the `expires` of the `user` is changed
/// The zero address for user indicates that there is no user address
event UpdateUser(uint256 indexed tokenId, address indexed user, uint64 expires);
/// @notice set the user and expires of a NFT
/// @dev The zero address indicates there is no user
/// Throws if `tokenId` is not valid NFT
/// @param user The new user of the NFT
/// @param expires UNIX timestamp, The new user could use the NFT before expires
function setUser(uint256 tokenId, address user, uint64 expires) external;
/// @notice Get the user address of an NFT
/// @dev The zero address indicates that there is no user or the user is expired
/// @param tokenId The NFT to get the user address for
/// @return The user address for this NFT
function userOf(uint256 tokenId) external view returns(address);
/// @notice Get the user expires of an NFT
/// @dev The zero value indicates that there is no user
/// @param tokenId The NFT to get the user expires for
/// @return The user expires for this NFT
function userExpires(uint256 tokenId) external view returns(uint256);
}
Additionally: - The userOf(uint256 tokenId)
function MAY be implemented as pure
or view
. - The userExpires(uint256 tokenId)
function MAY be implemented as pure
or view
. - The setUser(uint256 tokenId, address user, uint64 expires)
function MAY be implemented as public
or external
. - The UpdateUser
event MUST be emitted when a user address is changed or the user expires is changed. - The supportsInterface
method MUST return true when called with 0xad092b5c
.
Let's Write an ERC-4907!¶
Let's get started writing a rentable NFT! You can find the completed code here. We'll be importing Open Zeppelin's contracts, which provide secure, pre-written implementations of the ERC that our contract can just inherit!
Note that we will not be covering the basics of the ERC-721 standard. You can find a great Infura blog detailing what it is and how to implement it here.
Download System Requirements¶
You'll need to install:
- Node.js, v12 or higher
- truffle
- ganache UI or ganache CLI
Create an Infura account and project¶
To connect your DApp to Ethereum mainnet and testnets, you'll need an Infura account. Sign up for an account here.
Once you're signed in, create a project! Let's call it rentable-nft
, and select Web3 API from the dropdown
Register for a MetaMask wallet¶
To interact with your DApp in the browser, you'll need a MetaMask wallet. Sign up for an account here.
Download VS Code¶
Feel free to use whatever IDE you want, but we highly recommend using VS Code! You can run through most of this tutorial using the Truffle extension to create, build, and deploy your smart contracts, all without using the CLI! You can read more about it here.
Get Some Test Eth¶
In order to deploy to the public testnets, you'll need some test Eth to cover your gas fees! Paradigm has a great MultiFaucet that deposits funds across 8 different networks all at once.
Set Up Your Project¶
Truffle has some nifty functions to scaffold your truffle project and add example contracts and tests. We'll be building our project in a folder called rentable-nft
.
truffle init rentable-nft
cd rentable-nft
truffle create contract RentablePets
truffle create contract IERC4907
truffle create contract ERC4907
truffle create test TestRentablePets
Afterwards, your project structure should look something like this:
rentable-nft
├── contracts
│  ├── ERC4907.sol
│  ├── IERC4907.sol
│  └── RentablePets.sol
├── migrations
│  └── 1_deploy_contracts.js
├── test
│ └── test_rentable_pets.js
└── truffle-config.js
Write the ERC-4907 Interface¶
Now, let's add the interface functions defined in the EIP. To do this, go to IERC4907.sol
and change contract
to interface
. Then, we'll just copy and paste what was specified on the EIP! It should look like this:
// SPDX-License-Identifier: MIT
pragma solidity >=0.4.22 <0.9.0;
interface IERC4907 {
// Logged when the user of a NFT is changed or expires is changed
/// @notice Emitted when the `user` of an NFT or the `expires` of the `user` is changed
/// The zero address for user indicates that there is no user address
event UpdateUser(uint256 indexed tokenId, address indexed user, uint64 expires);
/// @notice set the user and expires of a NFT
/// @dev The zero address indicates there is no user
/// Throws if `tokenId` is not valid NFT
/// @param user The new user of the NFT
/// @param expires UNIX timestamp, The new user could use the NFT before expires
function setUser(uint256 tokenId, address user, uint64 expires) external;
/// @notice Get the user address of an NFT
/// @dev The zero address indicates that there is no user or the user is expired
/// @param tokenId The NFT to get the user address for
/// @return The user address for this NFT
function userOf(uint256 tokenId) external view returns(address);
/// @notice Get the user expires of an NFT
/// @dev The zero value indicates that there is no user
/// @param tokenId The NFT to get the user expires for
/// @return The user expires for this NFT
function userExpires(uint256 tokenId) external view returns(uint256);
}
Once you've created this file you shouldn't need to touch it again.
Write the ERC-4907 Smart Contract¶
Now, let's write an ERC-4907
smart contract that extends OpenZeppelin's ERC-721URIStorage
contract. First, install OpenZeppelin's contracts:
npm i @openzeppelin/[email protected]
The basics of an ERC-721 are covered in this Infura blog. We choose to use ERC721URIStorage
so that we don't have to use a static metadata file to populate the tokenURI
. To do this, import the interface we created and OpenZeppelin's ERC721URIStorage
implementation and have our ERC4907
smart contract inherit their properties as follows:
// SPDX-License-Identifier: MIT
pragma solidity >=0.4.22 <0.9.0;
import "@openzeppelin/contracts/token/ERC721/ERC721URIStorage.sol";
import "./IERC4907.sol";
contract ERC4907 is ERC721URIStorage, IERC4907 {
constructor() public {
}
}
Then, we'll modify the constructor to take in the NFT collection name and symbol when the contract is deployed.
contract ERC4907 is ERC721, IERC4907 {
constructor(string memory _name, string memory _symbol) ERC721(_name, _symbol){
}
}
Before we start implementing the functions defined in IERC4907
, let's set up two state variables UserInfo
and _users
to help define and store the concept of user
.
contract ERC4907 is ERC721URIStorage, IERC4907 {
struct UserInfo {
address user; // address of user role
uint64 expires; // unix timestamp, user expires
}
mapping(uint256 => UserInfo) internal _users;
UserInfo
stores the user's address and the rental expiration date_users
maps thetokenId
of the relevant NFT to the appropriateuser
(rentee)
Finally, let's get started on implementing the interface functions!
setUser¶
This function can only be called by the owner
of the NFT. It allows the owner to specify who will be the rentee of the NFT. The user now has the NFT in their wallet, but cannot perform any actions on it such as burn or transfer. Add this function to your ERC4907.sol
file:
/// @notice set the user and expires of a NFT
/// @dev The zero address indicates there is no user
/// Throws if `tokenId` is not valid NFT
/// @param user The new user of the NFT
/// @param expires UNIX timestamp, The new user could use the NFT before expires
function setUser(uint256 tokenId, address user, uint64 expires) public virtual override {
require(_isApprovedOrOwner(msg.sender, tokenId),"ERC721: transfer caller is not owner nor approved");
UserInfo storage info = _users[tokenId];
info.user = user;
info.expires = expires;
emit UpdateUser(tokenId, user, expires);
}
This function will update the UserInfo
struct with the address
of the rentee and the block timestamp that the renting period will expires
. We use the inherited function _isApprovedOrOwner
from ERC721
to indicate only the owner has the ability to decide who can user the NFT. Lastly, we will emit an UpdateUser
event defined in IERC4907
to communicate relevant information when setting a new user.
userOf¶
Next, we want to be able to identify who the current user of an NFT is. Add userOf
to your contract:
/// @notice Get the user address of an NFT
/// @dev The zero address indicates that there is no user or the user is expired
/// @param tokenId The NFT to get the user address for
/// @return The user address for this NFT
function userOf(uint256 tokenId)
public
view
virtual
override
returns (address)
{
if (uint256(_users[tokenId].expires) >= block.timestamp) {
return _users[tokenId].user;
} else {
return address(0);
}
}
This function takes the tokenId
as an argument and will return the user address
if that token is still being rented. Otherwise, the zero address indicates that the NFT is not being rented.
userExpires¶
Add the userExpires
function so that dapps can retrieve expiration date information for a specific NFT:
/// @notice Get the user expires of an NFT
/// @dev The zero value indicates that there is no user
/// @param tokenId The NFT to get the user expires for
/// @return The user expires for this NFT
function userExpires(uint256 tokenId) public view virtual override returns(uint256){
return _users[tokenId].expires;
}
If tokenId
does not exist, then a UserInfo
with default values will be returned. In this case, the default for the user
address will be address(0)
, and expires
, which is an uint64
, will be 0
.
supportsInterface¶
In order for a dapp to know whether or not our NFT is rentable, it needs to be able to check for the interfaceId
! To do so, override the supportsInterface
function as defined in the EIP-165 standard.
/// @dev See {IERC165-supportsInterface}.
function supportsInterface(bytes4 interfaceId)
public
view
virtual
override
returns (bool)
{
return
interfaceId == type(IERC4907).interfaceId ||
super.supportsInterface(interfaceId);
}
_beforeTokenTransfer¶
This is the final function we will implement! When the token is transferred (i.e., the owner changes) or burned, we want to remove the rental information as well. Note that this behavior is inherited from OpenZeppelin's ERC721
implementation. We will override _beforeTokenTransfer
from ERC721
to add in this functionality:
function _beforeTokenTransfer(
address from,
address to,
uint256 tokenId,
uint256 batchSize
) internal virtual override {
super._beforeTokenTransfer(from, to, tokenId, batchSize);
if (from != to && _users[tokenId].user != address(0)) {
delete _users[tokenId];
emit UpdateUser(tokenId, address(0), 0);
}
}
In order to delete the UserInfo
from the mapping, we want to make sure there was actually a transfer of ownership and there was UserInfo on it in the first place. Once verified, we can delete and emit an event that the UserInfo was updated!
Note that it is up to you, the contract writer, to decide if this is how you expect token transfers and burns to behave. You might choose to ignore this and say that rentees maintain their user status even when ownership changes!
Now, your final contract should look like this:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "./IERC4907.sol";
contract ERC4907 is ERC721URIStorage, IERC4907 {
struct UserInfo {
address user; // address of user role
uint64 expires; // unix timestamp, user expires
}
mapping(uint256 => UserInfo) internal _users;
constructor(string memory name_, string memory symbol_) ERC721(name_, symbol_) {}
/// @notice set the user and expires of a NFT
/// @dev The zero address indicates there is no user
/// Throws if `tokenId` is not valid NFT
/// @param user The new user of the NFT
/// @param expires UNIX timestamp, The new user could use the NFT before expires
function setUser(
uint256 tokenId,
address user,
uint64 expires
) public virtual override {
require(
_isApprovedOrOwner(msg.sender, tokenId),
"ERC721: transfer caller is not owner nor approved"
);
UserInfo storage info = _users[tokenId];
info.user = user;
info.expires = expires;
emit UpdateUser(tokenId, user, expires);
}
/// @notice Get the user address of an NFT
/// @dev The zero address indicates that there is no user or the user is expired
/// @param tokenId The NFT to get the user address for
/// @return The user address for this NFT
function userOf(uint256 tokenId)
public
view
virtual
override
returns (address)
{
if (uint256(_users[tokenId].expires) >= block.timestamp) {
return _users[tokenId].user;
} else {
return address(0);
}
}
/// @notice Get the user expires of an NFT
/// @dev The zero value indicates that there is no user
/// @param tokenId The NFT to get the user expires for
/// @return The user expires for this NFT
function userExpires(uint256 tokenId)
public
view
virtual
override
returns (uint256)
{
return _users[tokenId].expires;
}
/// @dev See {IERC165-supportsInterface}.
function supportsInterface(bytes4 interfaceId)
public
view
virtual
override
returns (bool)
{
return
interfaceId == type(IERC4907).interfaceId ||
super.supportsInterface(interfaceId);
}
function _beforeTokenTransfer(
address from,
address to,
uint256 tokenId
) internal virtual override {
super._beforeTokenTransfer(from, to, tokenId);
if (from != to && _users[tokenId].user != address(0)) {
delete _users[tokenId];
emit UpdateUser(tokenId, address(0), 0);
}
}
}
Write the RentablePets Smart Contract¶
Finally, we can write an NFT that utilizes the ERC4907
contract we just implemented. We are following the same NFT format as written in previous guides. You can look through those for a more in-depth explanation. We're exposing the burn
function so that we can test it. Don't include this method if you don't want your NFT to be transferrable!
Your final contract should look like this:
// SPDX-License-Identifier: MIT
pragma solidity >=0.4.22 <0.9.0;
import "./ERC4907.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
contract RentablePets is ERC4907 {
using Counters for Counters.Counter;
Counters.Counter private _tokenIds;
constructor() ERC4907("RentablePets", "RP") {}
function mint(string memory _tokenURI) public {
_tokenIds.increment();
uint256 newTokenId = _tokenIds.current();
_safeMint(msg.sender, newTokenId);
_setTokenURI(newTokenId, _tokenURI);
}
function burn(uint256 tokenId) public {
_burn(tokenId);
}
}
Start a Local Blockchain¶
In order to deploy and test our smart contracts, we'll need to modify migrations/1_deploy_contracts.js
like so:
const RentablePets = artifacts.require("RentablePets");
module.exports = function (deployer) {
deployer.deploy(RentablePets);
};
Next, let's get a local Ganache instance up. There are a variety of ways to do so: through the VS Code extension, Ganache CLI, and the Ganche graphical user interface. Each has its own advantages, and you can check out v7's coolest features here.
In this tutorial, we'll be using the GUI. Open it up, create a workspace, and hit save (feel free to add your project to use some of the nifty features from the Ganache UI)!
This creates a running Ganache instance at HTTP://127.0.0.1:7545.
Next, uncomment the development
network in your truffle-config.js
and modify the port number to 7545 to match.
development: {
host: "127.0.0.1", // Localhost (default: none)
port: 7545, // Standard Ethereum port (default: none)
network_id: "*", // Any network (default: none)
}
Test Your Smart Contract¶
If you want to test your smart contract commands on the fly without writing a full test, you can do so through truffle develop
or truffle console
. Read more about it here.
For the purposes of this tutorial, we'll just go ahead and write a Javascript test. Note that with Truffle, you have the option of writing tests in Javascript, Typescript, or Solidity.
We want to test the following functionality: 1. That RentablePets is an ERC721 and ERC4907 2. That setUser
cannot be called by someone other than the owner 3. That setUser
can be correctly called by the owner 4. That burn
will properly delete UserInfo
As part of testing, we'll want to make sure that events are properly emitted, as well as our require
statement failing correctly. OpenZeppelin has some really nifty test helpers that we'll be using. Download it:
npm install --save-dev @openzeppelin/test-helpers
The complete test looks like this:
require("@openzeppelin/test-helpers/configure")({
provider: web3.currentProvider,
singletons: {
abstraction: "truffle",
},
});
const { constants, expectRevert, expectEvent } = require('@openzeppelin/test-helpers');
const RentablePets = artifacts.require("RentablePets");
contract("RentablePets", function (accounts) {
it("should support the ERC721 and ERC4907 standards", async () => {
const rentablePetsInstance = await RentablePets.deployed();
const ERC721InterfaceId = "0x80ac58cd";
const ERC4907InterfaceId = "0xad092b5c";
var isERC721 = await rentablePetsInstance.supportsInterface(ERC721InterfaceId);
var isER4907 = await rentablePetsInstance.supportsInterface(ERC4907InterfaceId);
assert.equal(isERC721, true, "RentablePets is not an ERC721");
assert.equal(isER4907, true, "RentablePets is not an ERC4907");
});
it("should not set UserInfo if not the owner", async () => {
const rentablePetsInstance = await RentablePets.deployed();
const expirationDatePast = 1660252958; // Aug 8 2022
await rentablePetsInstance.mint("fakeURI");
// Failed require in function
await expectRevert(rentablePetsInstance.setUser(1, accounts[1], expirationDatePast, {from: accounts[1]}), "ERC721: transfer caller is not owner nor approved");
// Assert no UserInfo for NFT
var user = await rentablePetsInstance.userOf.call(1);
var date = await rentablePetsInstance.userExpires.call(1);
assert.equal(user, constants.ZERO_ADDRESS, "NFT user is not zero address");
assert.equal(date, 0, "NFT expiration date is not 0");
});
it("should return the correct UserInfo", async () => {
const rentablePetsInstance = await RentablePets.deployed();
const expirationDatePast = 1660252958; // Aug 8 2022
const expirationDateFuture = 4121727755; // Aug 11 2100
await rentablePetsInstance.mint("fakeURI");
await rentablePetsInstance.mint("fakeURI");
// Set and get UserInfo
var expiredTx = await rentablePetsInstance.setUser(2, accounts[1], expirationDatePast)
var unexpiredTx = await rentablePetsInstance.setUser(3, accounts[2], expirationDateFuture)
var expiredNFTUser = await rentablePetsInstance.userOf.call(2);
var expiredNFTDate = await rentablePetsInstance.userExpires.call(2);
var unexpireNFTUser = await rentablePetsInstance.userOf.call(3);
var unexpiredNFTDate = await rentablePetsInstance.userExpires.call(3);
// Assert UserInfo and event transmission
assert.equal(expiredNFTUser, constants.ZERO_ADDRESS, "Expired NFT has wrong user");
assert.equal(expiredNFTDate, expirationDatePast, "Expired NFT has wrong expiration date");
expectEvent(expiredTx, "UpdateUser", { tokenId: "2", user: accounts[1], expires: expirationDatePast.toString()});
assert.equal(unexpireNFTUser, accounts[2], "Expired NFT has wrong user");
assert.equal(unexpiredNFTDate, expirationDateFuture, "Expired NFT has wrong expiration date");
expectEvent(unexpiredTx, "UpdateUser", { tokenId: "3", user: accounts[2], expires: expirationDateFuture.toString()});
// Burn NFT
unexpiredTx = await rentablePetsInstance.burn(3);
// Assert UserInfo was deleted
unexpireNFTUser = await rentablePetsInstance.userOf.call(3);
unexpiredNFTDate = await rentablePetsInstance.userExpires.call(3);
assert.equal(unexpireNFTUser, constants.ZERO_ADDRESS, "NFT user is not zero address");
assert.equal(unexpiredNFTDate, 0, "NFT expiration date is not 0");
expectEvent(unexpiredTx, "UpdateUser", { tokenId: "3", user: constants.ZERO_ADDRESS, expires: "0"});
});
});
There's one special thing to call out here: To test that setUser
fails when msg.sender
is not owner
, we can fake the sender by adding the extra from
param:
rentablePetsInstance.setUser(1, accounts[1], expirationDatePast, {from: accounts[1]})
If you run into issues testing, using the Truffle debugger is really helpful!
Mint an NFT and View it in Your Mobile Wallet or OpenSea!¶
If you want to mint an NFT for yourself and view it in your mobile MetaMask wallet, you'll need to deploy your contract to a public testnet or mainnet. To do so, you'll need to grab your Infura project API from your Infura project and your MetaMask wallet secret key. At the root of your folder, add a .env
file, in which we'll put in that information.
WARNING: DO NOT PUBLICIZE OR COMMIT THIS FILE. We recommend adding .env
to a .gitignore
file.
MNEMONIC="YOUR SECRET KEY"
INFURA_API_KEY="YOUR INFURA_API_KEY"
Then, at the top of truffle-config.js
, add this code to get retrieve that information:
require('dotenv').config();
const mnemonic = process.env["MNEMONIC"];
const infuraApiKey = process.env["INFURA_API_KEY"];
const HDWalletProvider = require('@truffle/hdwallet-provider');
And finally, add the Goerli network to the networks
list under module.exports
:
goerli: {
provider: () => new HDWalletProvider(mnemonic, `https://goerli.infura.io/v3/${infuraApiKey}`),
network_id: 5, // Goerli's network id
chain_id: 5, // Goerli's chain id
gas: 5500000, // Gas limit used for deploys.
confirmations: 2, // # of confirmations to wait between deployments. (default: 0)
timeoutBlocks: 200, // # of blocks before a deployment times out (minimum/default: 50)
skipDryRun: true // Skip dry run before migrations? (default: false for public nets)
}
Your final truffle-config.js
should look something like this:
require('dotenv').config();
const mnemonic = process.env["MNEMONIC"];
const infuraApiKey = process.env["INFURA_API_KEY"];
const HDWalletProvider = require('@truffle/hdwallet-provider');
module.exports = {
networks: {
development: {
host: "127.0.0.1", // Localhost (default: none)
port: 7545, // Standard Ethereum port (default: none)
network_id: "*", // Any network (default: none)
},
goerli: {
provider: () => new HDWalletProvider(mnemonic, `https://goerli.infura.io/v3/${infuraApiKey}`),
network_id: 5, // Goerli's network id
chain_id: 5, // Goerli's chain id
gas: 5500000, // Gas limit used for deploys.
confirmations: 2, // # of confirmations to wait between deployments. (default: 0)
timeoutBlocks: 200, // # of blocks before a deployment times out (minimum/default: 50)
skipDryRun: true // Skip dry run before migrations? (default: false for public nets)
}
},
// Set default mocha options here, use special reporters, etc.
mocha: {
// timeout: 100000
},
// Configure your compilers
compilers: {
solc: {
version: "0.8.15", // Fetch exact version from solc-bin (default: truffle's version)
}
},
};
Then, we'll need to install the dev dependencies for dotenv
and @truffle/hdwallet-provider
. Lastly, run truffle migrate --network goerli
to deploy!
npm i --save-dev dotenv
npm i --save-dev @truffle/hdwallet-provider
truffle migrate --network goerli
Then, to quickly interact with the goerli network, we can use truffle console --network goerli
, and call the appropriate contract functions. We've already pinned some metadata to IPFS for you to use as your tokenURI: ipfs://bafybeiffapvkruv2vwtomswqzxiaxdgm2dflet2cxmh6t4ixrgaezumbw4
. It should look a bit like this:
truffle migrate --network goerli
truffle(goerli)> const contract = await RentablePets.deployed()
undefined
truffle(goerli)> await contract.mintNFT("YOUR ADDRESS", "ipfs://bafybeiffapvkruv2vwtomswqzxiaxdgm2dflet2cxmh6t4ixrgaezumbw4")
If you want to populate your own metadata, there are a variety of ways to do so - with either Truffle or Infura. Check out the guides here: - truffle preserve - infura IPFS
To view your NFT on your mobile wallet, open up MetaMask mobile, switch to the Goerli network, and open the NFTs tab! To view on OpenSea, you'll have to deploy to mainnet or Polygon. Otherwise, if you deploy your contract to rinkeby
, you can view it on https://testnets.opensea.io/
. To be aware that rinkeby
will be deprecated after the merge.
If you don't want to monitor your transactions in an Infura project, you can also deploy via Truffle Dashboard, which allows you to deploy and sign transactions via MetaMask - thus never revealing your private key! To do so, simply run:
truffle dashboard
truffle migrate --network dashboard
truffle console --network dashboard
Future Extensions¶
And there you have it! You've written a rentable NFT contract! Look out for a more in-depth guide for uploading your metadata to IPFS! For a more a detailed walkthrough of the code, be sure to watch the livestream on YouTube. In future editions of Web3 Unleashed, get excited to integrate this into a full-stack DApp. That is, a NFT rental marketplace that will use both the ERC-4907 rentable standard and ERC-2981 royalty standard
If you want to talk about this content, make suggestions for what you'd like to see or ask questions about the series, start a discussion here. If you want to show off what you built or just hang with the Unleashed community in general, join our Discord! Lastly, don't forget to follow us on Twitter for the latest updates on all things Truffle.