The challenge is to create a NFT contract that charges 1 ETH to mint but then stores the entire amount as collateral in a liquid staking token. As staking rewards come in they get distributed to the holders of the NFTs. At any time a user can burn the NFT to reclaim the 1 ETH.
Complexities With Withdrawals
I’m going to be using Lido Finance’s stETH for this which makes it very easy to deposit funds but withdrawing stETH back to ETH is complex and not possible in a single transaction because there is a withdrawal queue process.
For this reason I’ll be using Curve Finance ETH/stETH pool for distribution of the staking yield.
https://etherscan.io/address/0xDC24316b9AE028F1497c275EB9192a3Ea0f67022#code
I had loads of fun with this as I kept getting “Transaction reverted: function selector was not recognized and there’s no fallback nor receive function” errors which I was convinced had something to do with the interface and the Curve contract being written in Vyper but actually turned out to be because I hadn’t included a receive and fallback function to receive the returned ETH to the contract 🤦♂️
Gas Paying NFT Code
The full code for this is at:
Let’s write some code. First we want to import some libraries and interfaces. I’ll be using the ERC721 OpenZeppelin library and I’ll also need the IERC20 interface to move tokens around. Finally I need to expand the interface for Lido’s stETH token to include the submit() & sharesOf() custom functions and create a simple interface for the Curve pool:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "hardhat/console.sol";
interface ILido is IERC20 {
function submit(address _referral) external payable returns (uint256 StETH);
function sharesOf(address _owner) external returns (uint balance);
}
interface ICurve {
function exchange(int128 i, int128 j, uint256 dx, uint256 min_dy) external payable returns (uint256);
}
Next we will setup a pretty standard ERC721 NFT contract using state variable id to track the mints. I setup the stETH and curve interfaces and then use the raw.githubusercontent.com as the baseURI which probably isn’t best practice (use IPFS instead in production).
contract GotGas is ERC721 {
uint32 public id;
uint32 supply = 100;
uint public deposits;
ILido public stETH = ILido(0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84);
ICurve public curve = ICurve(0xDC24316b9AE028F1497c275EB9192a3Ea0f67022);
constructor() ERC721("GotGas", "GG") {}
function _baseURI() internal pure override returns (string memory) {
return "https://raw.githubusercontent.com/jamesbachini/Gas-Paying-NFT/main/json/";
}
We then need a mint function to enable users to mint new tokens. We are going to charge 1 ETH for each mint which will be added to the contract and converted to stETH so the contract can start earning yield.
function mint(address _to) public payable {
require(msg.value == 1 ether, "Send funds with TX");
require(id < supply, "Sold out");
stETH.submit{value: msg.value}(address(this));
deposits += msg.value;
id ++;
_safeMint(_to, id);
}
We also want a burn function so users can get their ETH back. For simplicities sake I’m just sending back the stETH token but you could swap it back first which might be a better UX.
function burn(uint _id) public {
require(ownerOf(_id) == msg.sender, "Not owner");
_burn(_id);
deposits -= 1 ether;
stETH.transfer(msg.sender, 1 ether);
}
Finally we have the function that caused all the problems. We need to check if the balance of stETH is higher than the total deposits which would because of the accumulation of staking rewards. Then we sell off the stETH surplus back to ETH using Curve and send it back to users.
Note that I’m using a loop here to go through each holder which is generally poor form. This limits the amount of tokens you can have in existence because at a certain point you would run out of gas. This isn’t an unbounded loop in this contract though because there is a limit on the supply.
At the end of the contract we need the fallback and receive functions to allow Curve to send the contract ETH… obviously 😡
function distribute() public {
uint fundsAvailable = stETH.balanceOf(address(this)) - deposits;
stETH.approve(address(curve), fundsAvailable);
uint min = fundsAvailable * 995 / 1000;
curve.exchange(1,0,fundsAvailable,min);
uint fundsPerUser = address(this).balance / id;
for (uint i = 1; i <= id; i++) {
payable(ownerOf(i)).transfer(fundsPerUser);
}
}
receive() external payable{}
fallback() external payable{}
Forking Mainnet & Writing Unit Tests
The next challenge is testing this which I decided to do by forking the ethereum mainnet state locally so I could use the current lido and curve contracts. To do this I used hardhat and alchemy.
npx hardhat node --fork https://eth-mainnet.alchemyapi.io/v2/YOURAPIKEY
npx hardhat test --network local
Now we have a test environment setup to mimic mainnet I wrote the following unit tests:
https://github.com/jamesbachini/Gas-Paying-NFT/blob/main/test/0x0.1.1-GotGas.js
These run through the standard functions of the contract to make sure it all works as expected. If I was going to deploy this in a production environment I would write more system tests to prod and probe to try and break the contract. Note there still may be bugs, this is for educational purposes only.
The most interesting part of the unit tests was stealing a whales stETH to simulate the staking rewards coming in.
it("Manipulate stETH Balance", async function () {
const balance1 = await stEth.balanceOf(gg.address);
const whaleAddress = "0x176F3DAb24a159341c0509bB36B833E7fdd0a132";
await network.provider.request({
method: "hardhat_impersonateAccount",
params: [whaleAddress],
});
const whale = await ethers.getSigner(whaleAddress);
const tenETH = ethers.utils.parseEther('10');
await stEth.connect(whale).transfer(gg.address, tenETH);
const balance2 = await stEth.balanceOf(gg.address);
expect(balance2).to.gt(balance1);
});
Here we are impersonating the account which I found from the token holders list on Etherscan and then sending some of their stETH to the contract.
This was a fun little project which might be compelling if the NFT market picks up at some point in the future. You could take it further by offering different mint value levels via an ERC1155 contract.
Essentially you are creating an upOnly NFT because there is a fixed floor of 1 ETH here. If price dropped below 1 ETH on secondary markets anyone could just buy them up and burn to get the collateral back. Plus holders get staking rewards while also having potential upside exposure of a value add NFT.