TreasureHunt

NFC treasure hunt at real events, now with ENS names, World ID sybil gates and Seal-encrypted clues

TreasureHunt

Created At

ETHGlobal Lisbon 2026

Winner of

World

World - Selfie Check Beta -- Continuity 2nd place

Project Description

Treasure Hunt turns physical events into on-chain scavenger hunts. Players tap NFC tags hidden around a venue, claim ERC-20 rewards gaslessly through ERC-4337 smart accounts, climb a live leaderboard, and spend those tokens on merch. It is not a hackathon demo: it has run in production at real events over the past eight months (ETHDenver, Festival da Juventude Cadaval, Smart Cities Summit, Data Summit), one deployment per event at <event>.treasurehunt.pt. The pre-existing app is the initial commit of this repo, tagged pre-hackathon; everything described below landed after that tag, in granular commits.

This weekend we added three capabilities the product could not have before.

ENS: every player has a name, and nobody pays for it. A self-hosted CCIP-Read wildcard resolver (ENSIP-10 + EIP-3668) answers for every name under our roots, so a player's handle becomes alice.lisbon.thehunt.eth with no transaction, no registry entry and zero marginal cost. The name is not cosmetic: it carries live game state as ENSIP-5 text records (pt.treasurehunt.score, .found, .editions, .scope), recomputed per request from the same database the app reads, so any ENS-aware client can read a player's game profile from the name alone. Resolvers are deployed and verified on Ethereum mainnet and Sepolia; chefmike.thehunt.eth resolves on mainnet today through ENS's own UniversalResolver. Purchased names are a second tier bought with in-game tokens through the existing merch rail and gated on an on-chain MerchPurchased event, and they are real ERC-721 tokens on the game chain: we mirrored ENS's own registry and registrar contracts onto the L3, where the registrar's baseNode is the same 32 bytes as namehash("thehunt.eth") on L1. One name identity, ownership on one chain, resolution on another. That is what makes the names outlive an event: a holder from a previous edition has no row in the current event's database and their name still resolves. Edition config also moved on-chain, into ENS's PublicResolver under cfg.thehunt.eth, and our ERC-4337 paymaster now resolves that name to decide which contracts it sponsors, so adding an event to the gas allowlist is a setText instead of an hardcoded variable change in the paymaster's proxy code.

World ID: gasless smart accounts create a real, economic sybil problem, because wallets are free and instant, so one person can farm tokens and drain limited merch stock with dozens of accounts. We put World ID at two points. Selfie Check gates merch redemption: the backend refuses to sign a purchase until the wallet has passed it, and the action-scoped nullifier is stored under two UNIQUE constraints, so one World ID backs at most one wallet and re-verifying from a second wallet is rejected. The same verification powers a verified-human badge and a verified-only leaderboard view. Identity Check gates 18+ prize pickup, requesting exactly one attribute (minimum_age: 18) and storing exactly one boolean. No document data, birth date or name reaches our servers.

Sui, Walrus and Seal: "The Lost Phrase", a side quest layered on the hunt. Four fragments of a clue phrase are encrypted with Seal and published on Walrus, public and content-addressed: anyone can fetch a ciphertext and verify we never swapped a clue mid-game. The player's browser pulls them straight from the public aggregator, so the ciphertext never passes through our infrastructure. Finding a marked quest tag reveals one fragment; the completed phrase leads to a final tag that appears on no map.

The point is where the decryption happens. The backend verifies on-chain that the player claimed that specific tag, signs a secp256k1 attestation over id ‖ bcs(session address) ‖ bcs(expiry), good for one fragment, one session and five minutes, then records a progress timestamp. That is the whole of its involvement. Nothing on our side ever contains plaintext: not a response, not a cache, not a log, and the progress table records only who revealed which fragment and when, with no column for the text itself. Outside the organiser's setup machine, the only plaintext copy in existence lives in the player's own browser, which holds an ephemeral Ed25519 SessionKey (no wallet, no gas, no signing prompt), fetches key shares directly from three key servers run by three independent operators (2-of-3), and decrypts locally. That takes about a second for a single fragment and three for the whole set of four, dominated by the key-server round trip.

Access is arbitrated by our own Move package on Sui testnet. Its seal_approve checks three things before a single share is released: that the key id belongs to this game's namespace, that the attestation has not expired against the on-chain Clock, and that the signature verifies against a backend public key frozen in an immutable GameConfig. It ships with two Move tests, one of them a test vector generated in JS that pins the attestation byte-for-byte across our backend and the on-chain verifier: message layout, hash flag and compact 64-byte signature. Take Seal out and the feature stops existing; there is no other path to the fragment text.

We are precise about the limit rather than waiting to be asked: the trust root is our attestation key, so the backend could attest to an address it controls. What it does not do is handle plaintext in operation, and it holds no decryption capability at rest. A leaked signing key still has to convince a threshold of independent operators, through a policy that is public on-chain. Removing that root means mirroring each find as a player-owned Sui object so the policy checks ownership instead of a signature, which needs sponsored transactions and stable per-player Sui addresses. That is a workstream, not an afternoon, and we scoped it out honestly.

How it's Made

Next.js 16 App Router, React 19, TypeScript, Tailwind v4, Neon Postgres, Foundry, Thirdweb v5 ERC-4337 smart accounts on an Arbitrum-Orbit L3 (Nova Cidade), all of which existed before the weekend. The three integrations sit on top of it.

ENS. The resolver contracts are vendored from ensdomains/offchain-resolver; the only change is OpenZeppelin ERC165 in place of ens-contracts' SupportsInterface. The gateway is three files with a deliberate split: lib/ens.ts is protocol only (selectors, DNS-wire parsing, name classification, digest, signing), lib/ens-records.ts is data only (one SQL round trip per record shape), lib/ens-gateway.ts is HTTP and EIP-3668 status semantics. A tricky part is the digest: the gateway signs the raw 32-byte keccak256(0x1900 | resolver | expires | keccak(request) | keccak(result)), and using signMessage instead adds a second EIP-191 prefix so every verification fails with upstream's misspelled "Invalid sigature". We pinned it from both sides with a golden vector: contracts/test/OffchainResolver.t.sol asserts the digest in Solidity and the TypeScript gateway asserts the same bytes, so changing either half fails on both. The chain id 93735000855 has no valid ENSIP-11 coinType at all because it overflows the 31-bit bound that ENSIP-19 makes normative, and viem's toCoinType throws rather than truncating, so the chain is stated instead through an ENSIP-24 data() record carrying an ERC-7930 interoperable address, an encoder we cross-checked against ENS DAO's own live on.eth registry; ENSIP-16 metadata() is implemented on-chain because it is called directly on the resolver, not through resolve(). addr is a fail-closed allow-list and returns null on mainnet on purpose: coinType 60 means the mainnet Ethereum address, our accounts exist only on the L3, and the account factory has zero bytes of code on mainnet, so publishing one there publishes a place to lose funds. Registry and registrar were mirrored from ensdomains/ens-contracts and deployed Blockscout and verified on the L3, with ERC721Enumerable added so a wallet's names come from the chain rather than an off-chain index, and reclaim kept as-is so a transfer leaves the registry record stale exactly as upstream does.

World ID. @worldcoin/idkit 4.2.1, lazy-loaded via next/dynamic so IDKit never enters the initial bundle. RP requests are signed server-side with @worldcoin/idkit-core/signing and re-fetched per widget open, because the RP signature TTL is 300 s and undocumented. Verification forwards the IDKit result as-is to POST /api/v4/verify/{rp_id}, and each proof is bound to the signed-in wallet by recomputing hashSignal(wallet) and comparing it to the response signal_hash, so a proof harvested elsewhere cannot be replayed against another account. Sybil enforcement is the database, not application logic: nullifiers stored as NUMERIC(78,0) with UNIQUE (nullifier, action) and (wallet_address, action). The gate lives inside the existing signed-purchase route, which 403s with the action name so the client opens the right widget and retries the same purchase. Both @worldcoin/idkit and @worldcoin/idkit-core have to stay direct dependencies because pnpm does not hoist and the server routes import the /signing and /hashing subpaths directly. Ten developer findings with reproductions are in docs/world-beta-testing.md.

Sui, Walrus, Seal. @mysten/seal and @mysten/sui, Walrus through its HTTP publisher and aggregator, plus @noble/curves for the attestation. The Move policy (move/sidequest/, ~75 lines of policy plus two tests) is composed from official primitives because there is no official pattern for signature attestation: the skeleton and namespace-prefix check from whitelist.move, expiry semantics from key_request.move, Clock from tle.move, and native sui::ecdsa_k1 for the verification. seal_approve checks three things: that the key identity is [GameConfig id][fragment order u64 LE] byte-for-byte, so a well-formed request for the wrong fragment fails; that the attestation has not expired, a five-minute window, against the on-chain Clock; and that the backend's secp256k1 signature over id | bcs(sender) | bcs(expiry_ms) verifies with the SHA256 flag against the frozen backend_pk, where sender is the player's ephemeral session address, so an attestation is useless to another player. Making that byte-compatible across JS and Move was the substance of the weekend: bcs little-endian layout, the SHA256 hash flag, low-S compact 64-byte signatures, and a raw message signed directly rather than through any wallet-style prefixing scheme, so the bytes Move hashes are exactly the bytes JS signed. One Move test asserts a JS-generated vector and checks that flipping a single byte of the id invalidates the signature; a second drives check_policy itself and asserts rejection for a wrong namespace prefix and for an expired attestation. One identity per fragment is mandatory rather than tidy, because Seal keys once fetched are held forever, so a shared identity would hand over the whole phrase on the first find. The GameConfig is frozen with no capability and no setters, so what the key servers' fullnodes resolve is fixed from setup onwards and never depends on fresh indexing. The trust boundary is enforced by file rather than by review: lib/attestation.ts is server-only and the sole holder of the signing key, lib/seal-client.ts is client-only and the sole decryptor, and neither imports the other, which makes the property checkable with grep. Entitlement is always re-read on-chain via the game's existing hasClaimedObjectReward, never from the progress table, which is why a find that predates the quest still counts and why revealing on a second device just works. Two venue-driven choices: one PTB carries a seal_approve call per fragment the player is entitled to, four on the sync path, and one batched fetchKeys covers them, with the SessionKey reused for its 30-minute TTL and persisted in IndexedDB, and already-decrypted fragments held in localStorage so they never go back to the key servers, because key-server rate limiting is source-based and a venue NAT is a single public IP for ~130 players; and the threshold is 2-of-3 across three independent operators (Mysten Labs, Ruby Nodes, NodeInfra) rather than three Mysten servers. Measured decrypt in a real browser: 975 ms at threshold 1, 1092 ms at 2-of-3. The Seal SDK reads chain state over JSON-RPC, which the official Sui testnet fullnode has moved away from, so the client points at a community RPC that still serves it. Operating the quest takes no SQL: the admin panel marks quest tags (fragment order, plus one hidden special tag, with a 409 if two tags claim the same order), and setup-sidequest.mjs reads that marking back from the database, encrypts, publishes to Walrus and writes the quest row, publishing only when the marking and the phrase line up and the on-chain GameConfig's backend_pk matches the local signing key. The script imports the same TypeScript libs the app uses via node --experimental-strip-types, so there is exactly one implementation of the identity derivation and the threshold. The phrase itself is in a gitignored quest.json, because it is the solution to the game. And the load-bearing test: take Seal out and the feature ceases to exist, because there is no alternative path to the fragment text.

background image mobile

Join the mailing list

Get the latest news and updates

TreasureHunt | ETHGlobal