The idea to build https://tokyo23.app came about after thinking of some key points we wanted to hit with a new NFT project:
Fun and interactive, an alternative to the pfp meta
Good UX - as far as can be achieved in the current ecosystem
Low requirements in terms of art assets - we needed to bootstrap the project while being both time and capital poor
On chain image composition has been an interest of mine for a while - so we decided to design a token that would be a single evolving NFT image that ties in with a real world "treasure hunt", prompting owners of the token to explore Tokyo, a culturally significant location for the kind of extremely online people that we love.
We came across a lot of challenges building this out, the first of which was designing the core flow of the experience itself.
The kind of people we are building for are sensitive about decentralization. One of the key challenges for this project was achieving an acceptable level of decentralization that didn't compromise on the experience that we wanted to offer.
The key idea was to have an evolving token that required users to seek out secret locations - but the concept of a secret doesn't work with so well with public smart contracts. It became clear that we needed some off chain component if only to conceal and validate these locations - so the concept of a "gamemaster" service that validated user locations against a secret list of goals was born.
The service would take user signatures containing coordinates, apply some additonal validations, and be responsible for constructing the proofs that would eventually be provided to the token contract to track which users were eligible for what unlocks. Image composition and unlock state would happen entirely on chain - this service would simply conceal and validate information to avoid trivializing the experience.
An initial concept for the gamemaster was for a list of "winners" who located a goal location to be generated after some time interval, and constructed into a merkle tree where the root would be stored on chain to be used as verification for NFT token upgrade claims. This is similar to how token rewards are distributed for certain defi protocols.
users would have to wait before they could see their progress reflected on chain, and it wasn't clear what kind of time interval made sense
updating the merkle root on chain is an awkward manual step.
The token upgrades didn't feel personal.
As I was thinking about these issues and wondering if we should abandon the project, I came across this blog post https://betterprogramming.pub/handling-nft-presale-allow-lists-off-chain-47a3eb466e44 by Humans of NFT, who had developed the concept of a signed "coupon" to facilitate a presale as an alternative to merkle tree proofs. This jumped out as a superior option for our case, and was a perfect extension for our already existing off chain service.
The core flow of the experience was changed to take advantage of this method, and now there was a system where users could "self serve" their unlocks at a time convenient to them while we kept the right amount of secrecy and security to preserve the fun.
Token holders sign their location and provide it to the gamemaster service
The gamemaster service verifies the signature, and keeps track of each holder's unlocks
When users want to upgrade their NFT and reflect their progress on chain, they ask the gamemaster service for a proof of progress
A proof is issued by the service, signed by its own private key, with a hash comprised of the requesting address and a bitmap integer representing the unlock state
the proof is validated in the token contract, checking with
ecrecoverthat the signer is in fact our gamemaster service, and that the sender of the transaction matches the address in the hashed combination of address + unlock state
This on chain validation is quite straightforward, with the final function looking like this:
// validate a signature, applying reported unlocks if valid
function applyUnlocks(uint256 unlockMap, bytes32 r, bytes32 s, uint8 v) public {
if (balanceOf(msg.sender) < 1) revert NotTokenHolder();
uint256 tokenId = tokenOf[msg.sender];
bytes memory encoded = abi.encode(msg.sender, unlockMap);
bytes32 signatureHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n", _toString(encoded.length), encoded));
address recovered = ecrecover(signatureHash, v, r, s);
// teamSigner here is the pubkey of our "gamemaster" service
if (recovered != teamSigner) {
revert InvalidSignature();
}
unlocks[tokenId] = unlockMap;
emit MetadataUpdate(tokenId);
emit UnlocksApplied(tokenId, unlockMap);
}
After finalizing the flow, the balance of on chain and off chain components came down to:
On chain / decentralized:
fully on chain image composition without external dependencies (more on this in a future post)
personal progress / unlock state preserved on chain after applying with a transaction
Off chain / centralized:
automated service that validates submissions and serves proofs of progress
signing key for those proofs
I'm happy with this outcome as it has a clear separation of roles - while the "game" is managed by the automatic service and signing keypair, the "token" is not compromised in the permanence of its metadata. Owners still benefit from the digital ownership and immutability promises that NFTs provide, but we add additional value with a lightweight experience layered on top.
With this aspect settled, a couple of things became obvious about how a standard NFT collection model wouldn't work here.
Firstly, the token is meant to track an individual's progress through a real world experience. It doesn't make sense to trade this token to someone else for money or any other reason.
Secondly, there wasn't a clear rationale for limiting total collection supply. While we might have been able to generate some FOMO with a limited mint, the goal of this project was more to act as more of a ticket to personal experience rather than any kind of investment or community membership signal. I couldn't think of a good reason to limit availability with that being the case.
So I did a few things to address this:
make the token "soulbound", aka disable transfers except for mint and burn. This was easily achieved with the following override of ERC721A
_beforeTokenTransfers
function _beforeTokenTransfers(address from, address to, uint256, uint256) internal pure override {
if (from != address(0) && to != address(0)) {
revert OnlyForYou();
}
}
leave token supply uncapped.
The end result is a NFT that is a bit different from common collections - but something that is a lot of fun to play with. If you are in Tokyo or plan to visit and want to try it out, it is available now at https://tokyo23.app .

