How Stock Hunt works, all of it.
How to claim
- Open the map on a phone. A laptop has no real GPS, and the claim server refuses a fix coarser than 65 m, which is all a laptop can give you.
- Connect a wallet and let it add Robinhood Chain, chain id 4663. You need a little ETH on that chain for gas, because you send the claim transaction yourself, and enough HUNT to hold a ticket. See below.
- Allow location. The status strip at the top shows your accuracy once it has a fix.
- Walk to a pin. The nearest one and its distance sit at the bottom of the screen, and the SHOW ME button flies the map to it.
- Within 40 m the button turns orange and says CATCH. Tap it, approve the transaction, and the fragment is in your wallet. The tx hash is right there to check on the explorer.
If the button will not turn orange, it is one of four things: no wallet connected, no location permission, a fix too coarse to trust, or you are further away than you look. The app says which one rather than failing silently.
Overview
Stock Hunt attaches fragments of real tokenized equities to the physical branches of the companies behind them. You open the map, walk to a pin, tap, and the fragment moves to your wallet on Robinhood Chain.
Three pieces make that safe. A spawn table says where a token appears and how much it pays. A claim server checks you are physically there and signs a voucher for exactly one wallet. A vault contract holds the tokens and releases them only against a valid voucher. Nothing is minted anywhere in that path: every fragment on the map was bought and deposited before it spawned.
The HUNT ticket
Holding HUNT is what makes a wallet eligible to claim. Wallets are free, so a reward with no gate is not a game, it is a faucet with a walk attached. The ticket is the one thing a fresh wallet cannot conjure.
Claiming does not spend or burn it. You hold it, you are eligible, and you keep it afterwards. Sell it and you stop being eligible. The balance is read from the chain by the claim server, never taken from the request, because a client that tells the server what it holds is not a gate.
HUNT is a fixed supply minted once at deployment. There is no mint function afterwards and no owner, so there is nothing to inflate and nothing to upgrade. Nine tests cover it, and the full contract suite is twenty six.
With no HUNT address configured the game runs open, which is how it behaves before the token exists.
Drops
A drop is a pin: a token, an amount, a rarity, a coordinate and a real place name. 438 pins are live across 40 cities. Each token has one rule that applies everywhere it spawns, so a pin is worth the same in Lisbon as in Tokyo.
Rarity is scarcity on this map, not a view about the company. Tesla puts a Supercharger on every motorway, so TSLA is common however you feel about Tesla. Netflix has two offices in the whole table, so NFLX is legendary because you will probably never stand next to one. Fragments are then sized off live share prices, so a rarity is worth what it says: about two cents common, six rare, fifteen epic, sixty legendary. Run npm run rebalance to redo it as prices move.
| TOKEN | FRAGMENT | RARITY | CONTRACT |
|---|---|---|---|
AMZN | 0.000077 | common | 0x12f190a9… |
GME | 0.0031 | rare | 0x1b0E319c… |
AMC | 0.022 | rare | 0x05a3d1Cd… |
XOM | 0.00013 | common | 0xf9B46d3D… |
MCD | 0.000066 | common | 0x08E91b65… |
LULU | 0.0002 | common | 0x4e620685… |
AAPL | 0.000062 | common | 0xaF3D76f1… |
GOOGL | 0.00018 | rare | 0x2e0847E8… |
MSFT | 0.00012 | rare | 0xe93237C5… |
META | 0.00024 | epic | 0xc0D6457C… |
NFLX | 0.0077 | legendary | 0xE0444EF8… |
TSLA | 0.000056 | common | 0x322F0929… |
COST | 0.000065 | rare | 0x4EA00516… |
NVDA | 0.00065 | epic | 0xd0601CE1… |
A pin can be caught 8 times a day, once per wallet, with 10 minutes between catches. A wallet catches at most 4 a day.
Claim flow
Six steps, four of them invisible.
- You connect a wallet and allow location. The app watches your fix.
- Within 40 m of a pin, with accuracy under 65 m, the the catch button turns orange.
- The app posts your address, the pin id and your fix to /api/claim.
- The server re-checks distance and accuracy, compares against your last accepted fix, applies every cap, then builds a voucher.
- The server signs it with the claim key over EIP-712 and returns it.
- Your wallet calls claim(voucher, signature). The contract verifies the signature, burns the nonce and transfers the fragment.
Voucher {
address to; // your wallet
address token; // e.g. AAPL 0xaF3D76f1…
uint256 amount; // 0.01 AAPL = 10000000000000000 (18 decimals)
bytes32 spawnId; // keccak256("london-aapl-1")
uint256 nonce; // random, single use, burned on redemption
uint256 deadline; // unix seconds, 15 minutes out
}The chain never trusts the app. It trusts one key, and that key only signs after the physical checks pass. If it leaks, the owner rotates it with setSigner and every outstanding voucher dies with it.
Vault contract
StockHuntVault.sol is deliberately small: it holds ERC-20 stock tokens and exposes one function to players.
function claim(Voucher calldata v, bytes calldata sig) external // reverts if: paused, past deadline, nonce used, signer mismatch, transfer fails // effects: burns the nonce, transfers v.amount of v.token to v.to, emits Claimed
Owner functions are setSigner, setPaused, withdraw and a two-step ownership handover. Anyone may relay a voucher, but the tokens always go to the address inside it, so relaying earns nothing. Signatures are EIP-712 under domain StockHunt / 1 / chainId 4663 / vault address, which makes a voucher for one vault worthless against another. Signature malleability is rejected, and a token that reports failure by returning false reverts the claim instead of silently paying nothing.
Seventeen tests cover it: replay, expiry, tampering with the amount or the recipient, a voucher aimed at a second vault, malleable signatures, pausing, key rotation, an empty vault, and a lying ERC-20. Run them with npm run vault:test.
Proof of reserves
The vault address is public and every balance below is read from the chain when you load this page. There is no off-chain ledger. If a token shows zero, that drop cannot be caught, and the map greys it out before you walk.
Streaks
Catching on consecutive days multiplies the fragment. Miss a day and the streak resets to one. The multiplier only ever spends more of a balance the vault already holds, so it cannot create supply.
day 14+ 2.00x day 7+ 1.50x day 3+ 1.25x day 1+ 1.00x
Anti-cheat
GPS can be faked and wallets are free, so per-wallet limits alone are decoration. Every counter here is keyed on something a fresh wallet does not reset.
- Radius and accuracy. 40 m, fix under 65 m. Indoor and IP-derived fixes are refused.
- Speed. Over 9 m/s between two catches is refused.
- Cooldowns. 15 minutes between catches by one wallet, 10 minutes before a pin can be caught again by anyone.
- Per network. 6 catches and 3 distinct wallets per network per day.
- Per device. 5 catches and 2 wallets per device per day.
- Per spot. 6 catches a day from the same 11 m square.
- Nonces and deadlines. One voucher, one use, 15 minutes.
- The network. Datacentre and commercial VPN addresses are refused, the connection's country has to match the country of the shop, and an address more than 400 km from the claimed fix is refused unless it is a mobile carrier, which geolocate badly.
That last one is the answer to a spoofed GPS. The fix arrives in a POST body, so the server can never see a satellite and a faked position costs nothing. What the server can see is where the request actually came from, and a VPN moves your IP without moving your GPS. The two stop agreeing, and the disagreement is the signal. Lookups are cached for a day, and one that times out lets the claim through rather than punishing someone for a slow third party.
It is not proof. A residential proxy in the right country, paired with a faked fix, still gets through. It moves the price of one fragment from nothing to renting a Danish home connection, which is the honest aim: make cheating cost more than the loot.
A rejected claim writes nothing, so nobody can exhaust a pin or someone else's allowance by spamming failures. Device attestation on a native app is the next layer; the browser id here is a speed bump, not a boundary, and it is worth saying so.
Tokens
Robinhood Chain stock tokens are ERC-20s, one contract per underlying, 18 decimals, tracking the share through a per-asset price feed. Chain id 4663, RPC rpc.mainnet.chain.robinhood.com, explorer robinhoodchain.blockscout.com. The app offers to add the network to your wallet.
This chain also has impostor tickers on it, so no address enters the registry unchecked. node scripts/verify-tokens.mjs re-reads symbol() and decimals() for all 14 and fails loudly on a mismatch.
Where the pins come from
Every coordinate is a real branch queried from OpenStreetMap through Overpass, not a guess typed from memory. The brand behind the counter has to be the company behind the ticker, which is why Odeon pays AMC, Esso pays XOM, and Micromania and EB Games both pay GME. If a city has no Lululemon in OSM, that city gets no LULU pin.
npm run spawns # rebuilds src/data/spawns.json from Overpass
Map data (c) OpenStreetMap contributors, ODbL.
Brands
A brand sponsors pins at its own doors. It funds the vault with its stock token, or pays us to buy it, and then pays per verified visit: one claim that passed every check above. Not per impression, not per click. The dashboard for this is the next thing to build.
API
GET /api/spawns?lat=&lng=&limit= pins sorted by distance, plus the drop rules
POST /api/claim { address, spawnId, lat, lng, accuracy, device }
-> { ok, mode, caught, voucher, signature, contract }
GET /api/claims public feed, addresses shortened
GET /api/claims?address=0x… one wallet's catches
GET /api/vault reserves, mode, and which state backend is liveErrors come back as { ok: false, error: "a human sentence", code } with a 4xx status. /api/claim is the only route that signs anything.
Demo vs live
The claim server stays in demo mode until both CLAIM_SIGNER_KEY and NEXT_PUBLIC_VAULT_ADDRESS exist. In demo mode every check still runs for real and your catch is logged, but no voucher is signed and nothing moves on chain. In live mode the voucher is signed and your wallet sends the transaction.
Caps and streaks live in Upstash Redis when its two env vars are set, and in memory otherwise. Memory is fine for demo and wrong for live: a cap that resets when a lambda recycles is not a cap. /api/vault reports which one is running.
Run it yourself
npm install && npm run dev # site on :3000, demo mode npm run vault:test # 17 tests against the vault # go live DEPLOYER_KEY=0x… CLAIM_SIGNER_KEY=0x… node scripts/deploy-vault.mjs # send stock tokens to the printed vault address # set NEXT_PUBLIC_VAULT_ADDRESS + CLAIM_SIGNER_KEY (+ Upstash vars), redeploy
The signer key never reaches the browser. It exists only inside the server route that builds vouchers, and lib/voucher.js imports server-only so a stray client import fails the build instead of leaking it.

AMZN
GME
AMC
XOM
MCD
LULU
AAPL
GOOGL
MSFT
META
NFLX
TSLA
COST
NVDA