documentation

How it works. All of it.

Overview

Hood Morning is a location game where the loot is real. Fragments of Robinhood Chain stock tokens are attached to physical places: the store or office of the company behind the token. You open the app, walk to the place, tap, and the fragment is transferred to your wallet on Robinhood Chain.

Three parts make it work: a spawn table (where and how much), a claim server that checks you are really there and signs a voucher, and a vault contract that holds the tokens and only releases them against a valid voucher. Nothing is minted. Every fragment on the map was bought and deposited before it spawned.

Drops

A drop is a spawn point: a token, an amount, a rarity, coordinates and a place name. Today 721 drops are seeded across 121 cities. Each token has one drop rule that applies everywhere:

TokenFragmentRarity
AMZN0.008common
GME0.11common
AAPL0.011rare
GOOGL0.011rare
META0.0055rare
MSFT0.007rare
TSLA0.013epic
NVDA1 full sharelegendary

Drops refill every day at midnight UTC. A drop can be caught at most 24 times per day, a wallet can catch at most 6 drops per day and the same drop once per day. Legendary drops (a full NVDA share at Nvidia HQ) are monthly events announced 24 hours ahead and require scanning a code on site.

Claim flow

Six steps, four of them invisible:

  1. You connect a wallet and enable location. The app watches your GPS position.
  2. When you are close enough to a drop with accuracy under 65 m, the catch button unlocks. Close enough is 40 m plus half the error your phone reports — 45 m on a clean fix, 73 m at the accuracy ceiling — because a downtown fix is routinely twenty to fifty metres out and a flat radius only ever refused people who were standing there.
  3. The app sends your wallet address, the drop id and your fix to POST /api/claim.
  4. The server re-checks distance and accuracy, reads the GPS log behind the fix, compares it with your previous accepted fixes (no teleporting, no city hopping inside an hour), applies the daily caps, then builds a voucher: to, token, amount, spawnId, nonce, deadline (15 minutes).
  5. The server signs the voucher with the claim key (EIP-712) and returns it.
  6. Your wallet calls claim(voucher, signature) on the vault. The contract verifies the signature, burns the nonce and transfers the fragment to you.
Voucher {
  address to;       // your wallet
  address token;    // e.g. AAPL 0xaF3D…93f9
  uint256 amount;   // 0.01 AAPL = 10000000000000000 (18 decimals)
  bytes32 spawnId;  // keccak256("paris-0")
  uint256 nonce;    // unique, burned on use
  uint256 deadline; // unix seconds
}

The chain never trusts the app. It trusts one key, and that key only signs after the physical checks. If the key leaks, the owner rotates it with setSigner and pauses the vault.

Vault contract

TouchGrassVault.sol is deliberately small. It holds ERC-20 stock tokens and exposes one user function:

function claim(Voucher calldata v, bytes calldata sig) external
  // reverts if: paused, past deadline, nonce already used, signer mismatch
  // effects: marks nonce used, transfers v.amount of v.token to v.to, emits Claimed

Owner functions: setSigner, setPaused, withdraw, transferOwnership. Anyone can relay a voucher, but tokens always go to the address inside it. Signatures use EIP-712 with domain TouchGrass / 1 / chainId 4663 / vault address, so a voucher for one vault is worthless on another. Source lives in contracts/, compiled with solc-js by scripts/compile-vault.mjs, deployed by scripts/deploy-vault.mjs.

Proof of reserves

The vault address is public and every balance below is read from Robinhood Chain when you load this page. There is no off-chain ledger: if a token shows zero drops here, that drop cannot be caught, and the app says so before you walk.

the vault, live from the chain
Reading…
drops payable right now

Every number above is a balanceOf call on Robinhood Chain, refreshed every minute. Click any token to see the same balance on the explorer. Nothing is minted: a drop only spawns if the vault can pay it. The treasury is the ETH that buys what goes in the vault next, and 37.5% of every $GRASS fee lands in it.

Legendaries

A legendary is one whole share, at one place, during one hour, with exactly one winner. It is announced 24 hours ahead with a live countdown on the home page, so people can plan to be there.

  • Tighter radius than a normal drop, 25 m by default.
  • A short code is physically stuck at the location. No code, no prize, which is what makes GPS spoofing useless here.
  • The winner slot is written before the voucher is signed, so two people tapping in the same second cannot both win. The loser gets a clear message.
  • The vault must hold the full share before the window opens. The countdown says so when it does not.
  • Past winners stay listed, and every transfer is on the explorer.
POST /api/legendary            schedule one (admin, Bearer CRON_SECRET)
     { symbol, amount, place, city, lat, lng, startsAt, durationMin, radiusM }
     -> returns the code once. Print it, stick it at the spot.
GET  /api/legendary            the event without its code, plus past winners
POST /api/legendary/claim      { address, lat, lng, accuracy, code }

Anti-cheat

GPS can be faked and wallets are free, so per-wallet limits alone are worthless. Every counter below is keyed on something a fresh wallet does not reset:

  • Radius and accuracy. 4073 m radius, scaled to the error your own fix reports, and GPS accuracy under 65 m. Indoor and coarse IP fixes are rejected. Widening with the error bar costs nothing here: a fabricated position sits on the pin at zero metres, so the radius was never what refused it — the GPS log was.
  • Travel. Every accepted fix is kept, per wallet and per device. A new catch has to be reachable from the last one: walking speed (9 m/s) under 300 km, and a plane plus 90 minutes of airport beyond it. On top of that, two different cities inside one hour is refused whatever the distance, wallet or no wallet: the same rule follows the device, so making a fresh wallet does not clear the trail behind your phone.
  • Proof of presence. The app sends the GPS log behind your fix, not just the fix: at least 4 readings over 20 seconds. A real chipset wobbles by centimetres and its accuracy drifts every second. A mock provider returns the same coordinates and the same ±5 m forever, and that is exactly what we look for. Positions typed with five decimals, fixes landing on the pin to the centimetre, and accuracies no phone can produce are refused too.
  • Cross-checks that GPS cannot fake. Your connection is geolocated at the edge: a fix more than 500 km from the network you are actually on is refused. So is a device clock more than 3 hours off the timezone of the place you claim to stand in. Spoofing the GPS is one tap; spoofing your IP, your timezone and your accelerometer at the same time is a job.
  • Motion. On a phone we read the accelerometer while you stand there. Hands shake, emulators do not. A perfect zero is refused.
  • Cooldowns. 120 minutes between two catches by the same wallet, 10 minutes before the same drop can be caught again by anyone.
  • Per connection and per device. 40 catches and 60 distinct wallets per network per day, 60 catches and 50 wallets per device. Making new wallets does not help.
  • Per spot. 24 catches per day from the same 11 m square, whoever is standing there.
  • Per wallet and per drop. 6 catches per wallet per day, 24 per drop per day, one catch per drop per wallet.
  • Worldwide throughput. On top of every per-walker rule, the whole site shares one allowance that refills continuously, so nobody drains the vault faster than the keeper refills it. It is a bucket, not a counter that resets on the clock: there is no round minute to camp on, being early buys nothing, and the credit is spent inside a single atomic operation, so requests fired together cannot all slip through at once. One machine may only take a couple of credits before it has to wait, whatever number of wallets it owns. The live values are in GET /api/vault under limits.
  • Nonces and deadlines. A voucher is single-use and expires in 15 minutes. Replays are impossible.

A rejected claim consumes nothing, so nobody can burn someone else's allowance. Device attestation on the native app is next.

Tokens

Robinhood Chain stock tokens are ERC-20s issued by Robinhood, one contract per underlying, 18 decimals, tracking the share price through a per-asset price feed. Robinhood offers them in 120+ countries, not to US persons; on-chain they are ordinary ERC-20s and move like any other. 42 tokens are wired into the app today, each with its on-chain icon and contract address in src/lib/tokens.json, regenerated from Robinhood's public registry by scripts/fetch-tokens.py.

Chain id 4663, RPC https://rpc.mainnet.chain.robinhood.com, explorer robinhoodchain.blockscout.com. The app adds the network to your wallet automatically.

Brands

A brand sponsors drops at its own doors. It funds the vault with its stock token (or pays us to buy it), we schedule spawns, and it pays per verified visit: a claim that passed every check above. Not per impression, not per click. The dashboard for this is the next thing we build. Until then, DM @HoodMorningRWA on X.

API

GET  /api/spawns?lat=&lng=&limit=       drops sorted by distance (all if no position)
POST /api/claim  { address, spawnId, lat, lng, accuracy }
                                        → { ok, mode, voucher, signature, contract, chainId }
GET  /api/claims                         public feed, addresses shortened
GET  /api/claims?address=0x…             one wallet's catches
POST /api/early-access { email, kind, city?, company? }

Errors come back as { ok: false, error: "human sentence" } with a 4xx status. The claim endpoint is the only one that signs anything.

Demo vs live

The claim server runs in demo mode until two env vars exist: CLAIM_SIGNER_KEY (the signing key) and NEXT_PUBLIC_VAULT_ADDRESS (a deployed, funded vault). In demo mode every check runs for real and the catch is logged under your wallet, but no voucher is signed and nothing moves on-chain. In live mode the voucher is signed and your wallet sends the claim transaction.

State (caps, last fix, catches, signups) lives in Redis when UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN or Vercel KV vars are set, and in memory otherwise.

Keeper

A Vercel cron job calls /api/cron/fund every hour. It reads how many drops the vault can still pay for each token, and for every token under the target (10 drops by default) it buys more with the treasury's ETH through the TouchGrassSwapper contract: ETH to USDG to the stock token in one transaction, output delivered straight into the vault. Each buy is simulated first, capped per run, and logged. The treasury key is the on/off switch: no key, no purchases.

Tokens without an on-chain pool yet (NFLX, LULU, COST, UPS, NU) are skipped and must be deposited by hand. The last keeper runs are visible in /api/vault.

Run it yourself

npm install && npm run dev            # site on :3000, demo mode

# go live
node scripts/compile-vault.mjs        # → src/lib/vault.artifact.json
DEPLOYER_KEY=0x… CLAIM_SIGNER_KEY=0x… node scripts/deploy-vault.mjs
# fund the vault: send stock tokens to the printed address
# set NEXT_PUBLIC_VAULT_ADDRESS + CLAIM_SIGNER_KEY (+ Upstash vars) in Vercel, redeploy

Never put the signer key in the browser. It only exists in the server route that signs vouchers.