MORE or LESS experiment
A post-mortem by Manifold Engineer @yungwknd on his recent personal NFT project, MORE or LESS.
On September 1st, I released my MORE or LESS experiment, something I had been working on for nearly a month (about 4 years in NFT Twitter). First, I want to give my deepest gratitude to everyone who participated, it really meant the world to me! Thank you thank you thank you!
If you're not familiar with the experiment - it was a performance piece of art aimed at answering a question I asked back in July:
I translated this from a question to an on-chain social experiment where I asked participants to choose a purchase price of either 0.01 or 0.1 ETH for a solidity-generated SVG piece of art. The additional catch was that whichever category got the most votes, the ETH for that category would be donated. This played out with 613 mints at 0.01 ETH and 386 mints at 0.1 ETH.
The Launch of the Experiment
The morning of the launch was a bit hectic. I made some last minute changes, had to transfer more ETH into my deploying wallet, and finally deployed the contract 15 minutes before it was supposed to go live. I then joined the Twitter Spaces hosted by my colleague Richerd at 9am, while still scrambling to launch the website. At 9:04am I committed the website to http://moreorless.eth.link/ and we were live! The only issue was that the EthDNS was not updating. Anyway, we were live, and people were using the IPFS link to mint!
From the first mint to the last took 401 blocks, or about 82 minutes. This was a very pleasant surprise and it was so much fun watching live as the mints came in. In the end, there were 386 votes for MORE and 613 votes for LESS.
An additional fun surprise to me was the secondary sales on the day of the launch and during the initial minting phase. On that first day there was 125 secondary sales. This surprised me because each token has the minter's address on it, which I put there to be more personalized for the minter. However, it is still great to see that there are 848 unique owners across the 999 pieces.
The Technology Behind the Experiment
Let's get into the tech now. You could say I'm an on-chain maximalist. One of the goals of this project was to make it entirely on-chain and as crypto-native as possible. To that extent - I wrote an on-chain library to generate the SVG. You can find the entire library on Etherscan but here is just a snippet of what it looks like, demonstrating the circle generation:
struct Art {
uint8 numRects;
uint8 numCircles;
uint8 numTriangles;
uint8 numLines;
uint8 whichShape;
uint48 randomTimestamp;
uint128 randomDifficulty;
uint256 randomSeed;
}
function getCirclePalette() internal pure returns(string[5] memory) {
return ['%230F2A38', '%231D3C43', '%232A4930', '%23132F13', '%23092409'];
}
function _generateCircles(Art memory artData) internal pure returns (string memory) {
string memory circles = '';
string[5] memory colorPalette = getColorPalette(artData.randomSeed, artData);
for (uint i = 0; i < artData.numCircles; i++) {
circles = string(abi.encodePacked(
circles,
"<ellipse cx='",
seededRandom(0, 1000, artData.randomSeed + i, artData).toString(),
"' cy='",
seededRandom(0, 1000, artData.randomSeed - i, artData).toString(),
"' rx='",
seededRandom(0, 100, artData.randomSeed + i - 1, artData).toString(),
"' ry='",
seededRandom(0, 100, artData.randomSeed - i + 1, artData).toString(),
"'",
" fill='",
colorPalette[seededRandom(0, 5, artData.randomSeed + i, artData)],
"'",
"/>"));
}
return circles;
}
Essentially, it's a number of functions that generate and return a string containing the desired SVG markup.
As each token is minted, I save all the info needed for generation as part of the Art
struct, and the use that to generate the SVG whenever the image needs to be re-rendered. In addition, this information is used to generate the token's metadata, such as the number of circles and minter's address.
As part of this experiment, I also made a couple of playground features so that I could get a feel of the diversity of outputs. You can find an individual token generator here and a bulk generator here.
Unexpected Behaviors
There were a number of happy little accidents in the code that I'll describe here, in order from the least to most impactful.
The first bug we'll take a look at is regarding the Decider attribute. I intended this to be for the token that pushed one category over the edge to be the winner. This is the code for if it is the MORE vote that wins:
voteInfos[mintNum].isTheDecider = moreVotes == 10 && lessVotes < 10;
If you can't find the issue, that's okay, I missed it too. The 10s above should be 500s. I was testing with 19 tokens instead of 999, and forgot to update this line. What does this mean in practice? Well, token #12 has the secret attribute, when it should have been token #829. Does this make either of them more sought after? Only time will tell.
The next bug, or happy accident, was in how I generated the random seeds for each mint. Here is this code:
uint256 num = uint256(keccak256(abi.encodePacked(block.timestamp, block.difficulty, "MORE"))); artInfos[mintNum].randomTimestamp = uint48(block.timestamp); artInfos[mintNum].randomDifficulty = uint128(block.difficulty); artInfos[mintNum].randomSeed = num;
What's going on here? Well, I take the block difficulty, timestamp, and vote into account for creating the random number. This was fine in testing, when I was generating one at a time. However, it fails to account for many votes going into the same block. This brought about what I call the "twin mints", which you can see if you scroll chronologically through the collection. The first instance of this we see is in mints #10 and #11, both of which were mined in block 13140797. You can see them here:
It It might be hard to tell because some of the triangle colors are the same as the background, but these have the same everything except the background color. As you continue through the collection you can find more twins like this, which I quite enjoy! The only downside here is that there is less diversity in output, and the algorithm isn't used to its fullest potential.
The final bug, which was the most disappointing, was in the pricing requirement. Let's see if you can catch it:
require(msg.value > 100000000, 'Not enough ETH');
The intent here was to restrict purchases for the MORE token to over 0.1 ETH. In this case, the restriction is off by a few zeroes and translates to 0.0000000001 ETH (or 0.1 Gwei). Ultimately, a number of folks found this bug and took advantage of it. Thankfully, the requirement of 1 mint per wallet slowed down the issue. As a result of this, the contract is short about 16 ETH. In the end, it should have held 44.73 ETH, though it only reached 28.409 ETH. Of course, I am still committing to donating the amount by the winning vote, which is 6.13 ETH.
Key Learnings
I'm a firm believer that mistakes create learning opportunities. While there may have been some errors in the contract, I learned a ton from the experiment,
The first thing I learned was that gas optimization matters and is not that difficult (to an extent). About 48 hours before I was set to launch the project, I noticed that the test transactions were consuming as much as 2 million gas. To put this in perspective, transferring ETH from one wallet to another costs 21,000 gas, about 1/100th the amount. After some quick debugging with one of my colleagues at Manifold, we found that it was because I was storing the entire SVG string on-chain, but all that I really needed to do was store the inputs to the SVG-generation function. Making the change took about 10 minutes but saved nearly 90% on gas, reducing the gas usage to under 300,000 per transaction.
Another key learning was about on-chain randomization. In my code, I randomize solely based on the block, as shown above. However, this does not work well for cases where your project sells out quickly enough to have multiple mints-per-block. A better way to randomize could be like so:
uint256 num = uint256(keccak256(abi.encodePacked(tokenId, block.timestamp, block.difficulty, "MORE")));
The final learning was to test, test, test. While I had tests for the contract, they were written to pass, not to test exploitive behavior. For example, regarding the pricing issue: I tested that it was successful with 0.1 ETH and unsuccessful with 0 ETH. Testing with other amounts, as well as changing the contract to use strict equality for the amount would have caught the issue earlier. For example, this is how you could change the price requirement:
// from
require(msg.value > 100000000, 'Not enough ETH');
// to
require(msg.value == 100000000, 'Not enough ETH');
By doing this, I would have quickly found out that my units were incorrect, and the value should have been 100000000000000000
.
In Review
Overall I would say the MORE or LESS experiment was a huge success. I am really happy with the results and I learned a ton from writing the contract and website.
I hope you enjoyed the experiment as much as I did! If you'd like to contact me, please reach out on Twitter to @yungwknd.
If you want to read even MORE about the experiment, check out this interview I did with JO7.