Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,18 @@ Required keys: MAPBOX_TOKEN, PINATA_API_KEY, PINATA_API_SECRET, KAFKA_BOOTSTRAP,
## Branch Strategy

- `main` — production-ready code
- `develop` — integration branch
- Feature branches off `develop`, PRs back to `develop`
- `ready-to-go` — current integration branch where active work lands (ahead of `main`)
- Feature branches off `ready-to-go`, PRs back to `ready-to-go`

> ⚠️ Divergence to reconcile: CI (`ci.yml`) and testnet deploy (`deploy-testnet.yml`)
> still trigger on a `develop` branch that does not currently exist on the remote.
> Either create `develop` as the integration branch or repoint those workflows at
> `ready-to-go`.

## CI/CD

- GitHub Actions: Aiken build/test, Hardhat compile/test/coverage, Python pytest, Node typecheck/lint/build
- Testnet deploy: push to `develop` triggers Cardano Pre-Prod + Base Sepolia deployment
- Testnet deploy: push to `develop` triggers Cardano Pre-Prod + Base Sepolia deployment (see divergence note above — `develop` is currently absent)
- Vercel: `apps/web` auto-deploys from `main`

## Conventions
Expand Down
7 changes: 5 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
"dev": "next dev --webpack",
"build": "next build --webpack",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@magic-sdk/admin": "^2.8.2",
Expand Down Expand Up @@ -38,6 +40,7 @@
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.1.9"
}
}
25 changes: 25 additions & 0 deletions apps/web/src/app/api/hexes/holds/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest'
import { GET } from './route'
import { issueGlobalHold } from '@/lib/global-hold-store'

// In-process test — a single Vitest process shares one memKv, so this exercises the
// real route handler end-to-end (live dev can't, because memKv doesn't persist across
// Next's per-request module boundaries without Redis configured).
describe('GET /api/hexes/holds', () => {
it('serves active holds as a GeoJSON FeatureCollection', async () => {
const SYD = '84be0e3ffffffff'
await issueGlobalHold({ hexId: SYD, email: 'overlay@example.com', lat: -33.87, lng: 151.21 })

const res = await GET()
const body = (await res.json()) as {
type: string
features: Array<{ geometry: { type: string }; properties: { id: string; status: string } }>
}

expect(body.type).toBe('FeatureCollection')
const feature = body.features.find((f) => f.properties.id === SYD)
expect(feature).toBeDefined()
expect(feature?.geometry.type).toBe('Polygon')
expect(feature?.properties.status).toBe('held')
})
})
18 changes: 18 additions & 0 deletions apps/web/src/app/api/hexes/holds/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server'
import { listActiveHolds } from '@/lib/global-hold-store'
import { hexToGeoJSON } from '@/lib/h3'

export const dynamic = 'force-dynamic'

/**
* Active global holds as a GeoJSON FeatureCollection, for the map overlay.
* These are off-chain reservations of Res-4 cells outside the curated 200.
*/
export async function GET() {
const holds = await listActiveHolds()
const features = holds.map((h) => ({
...hexToGeoJSON(h.hexId),
properties: { id: h.hexId, status: 'held', heldAt: h.heldAt, expiresAt: h.expiresAt },
}))
return NextResponse.json({ type: 'FeatureCollection', features })
}
33 changes: 33 additions & 0 deletions apps/web/src/app/api/hexes/resolve/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server'
import { resolveCellStatus } from '@/lib/cell-status'
import { resolveContainingCell } from '@/lib/hex-lookup'

export const dynamic = 'force-dynamic'

/**
* Resolve a cell's reservation status for the address-lookup flow.
* GET ?hex=<h3 index> → status of that cell
* GET ?lat=<n>&lng=<n> → status of the Res-4 cell containing the point
* Returns { hexId, resolution, status, lat, lng, priceUsd }.
*/
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const hexParam = searchParams.get('hex')
const lat = Number(searchParams.get('lat'))
const lng = Number(searchParams.get('lng'))

let hexId: string
if (hexParam) {
hexId = hexParam
} else if (Number.isFinite(lat) && Number.isFinite(lng)) {
hexId = resolveContainingCell(lat, lng)
} else {
return NextResponse.json({ error: 'Provide ?hex= or ?lat=&lng=' }, { status: 400 })
}

try {
return NextResponse.json(await resolveCellStatus(hexId))
} catch {
return NextResponse.json({ error: 'Invalid H3 cell' }, { status: 400 })
}
}
87 changes: 87 additions & 0 deletions apps/web/src/app/api/holds/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { NextResponse } from 'next/server'
import {
issueGlobalHold,
getGlobalHold,
releaseGlobalHold,
listHoldsByEmail,
} from '@/lib/global-hold-store'
import { resolveContainingCell } from '@/lib/hex-lookup'
import nativeHexesData from '@/data/genesis-native-hexes.json'

const NATIVE_HEX_SET = new Set(Object.keys(nativeHexesData as Record<string, unknown>))

export const dynamic = 'force-dynamic'

/**
* Global hex holds — off-chain exclusive reservations of Res-4 cells outside the
* curated Genesis 200. Never mints, never consumes a Genesis edition.
*
* POST { email, lat, lng, referrerId? } → place a hold (server derives the cell)
* GET ?email=… → a user's holds | ?hex=… → a single cell's hold
* DELETE { hexId, email } → release your own hold
*/
export async function POST(req: Request) {
try {
const { email, lat, lng, referrerId } = await req.json()

if (!email || typeof email !== 'string') {
return NextResponse.json({ error: 'Missing required field: email' }, { status: 400 })
}
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
return NextResponse.json({ error: 'lat and lng must be numbers' }, { status: 400 })
}

// Derive the cell server-side so a hold always matches its coordinates.
const hexId = resolveContainingCell(lat, lng)

// Native-reserved cells are held for Native Tribes first — not publicly holdable.
if (NATIVE_HEX_SET.has(hexId)) {
return NextResponse.json(
{ error: 'This cell is on tribal land and reserved for Native Tribes', nativeReserved: true },
{ status: 403 },
)
}

const result = await issueGlobalHold({ hexId, email, lat, lng, referrerId })
if (!result.ok) {
return NextResponse.json(
{ error: result.error, hexId, alreadyHeld: true },
{ status: 409 },
)
}

return NextResponse.json({ ok: true, hold: result.hold }, { status: 201 })
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
}
}

export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const email = searchParams.get('email')
const hex = searchParams.get('hex')

if (hex) {
return NextResponse.json({ hold: await getGlobalHold(hex) })
}
if (email) {
return NextResponse.json({ holds: await listHoldsByEmail(email) })
}
return NextResponse.json({ error: 'Provide ?email= or ?hex=' }, { status: 400 })
}

export async function DELETE(req: Request) {
try {
const { hexId, email } = await req.json()
if (!hexId || !email) {
return NextResponse.json({ error: 'Missing required fields: hexId, email' }, { status: 400 })
}
const result = await releaseGlobalHold(hexId, email)
if (!result.ok) {
return NextResponse.json({ error: result.reason }, { status: 403 })
}
return NextResponse.json({ ok: true })
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
}
}
20 changes: 20 additions & 0 deletions apps/web/src/app/lookup/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import AddressHexLookup from '@/components/AddressHexLookup'

export const metadata = {
title: 'Address → Hex Lookup',
description: 'Find the Res-4 hex cell that contains your address.',
}

export default function LookupPage() {
return (
<main className="mx-auto min-h-screen max-w-xl px-4 py-16">
<h1 className="text-2xl font-bold text-white">Find your hex</h1>
<p className="mt-2 text-sm text-gray-400">
Enter your address to see the Res-4 H3 cell (~1,770 km²) that contains it.
</p>
<div className="mt-8">
<AddressHexLookup />
</div>
</main>
)
}
Loading