From 4fef930029645c9052a8446fc8cae6020f1f148c Mon Sep 17 00:00:00 2001 From: ssmithx Date: Wed, 5 Aug 2026 17:20:35 +0000 Subject: [PATCH] Add 500km link cap, block-height-based claim expiry, and link count in score MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MAX_LINK_DISTANCE_KM (default 500): flat cap on link length. Real Ingress scales this off portal/resonator level (up to ~655km); Regress has no leveling system to scale off of, so this is a single ceiling. - PORTAL_TIMEOUT_BLOCKS (default 2100): claims stamp the current Bitcoin block height (claimed_at_block, new migration) and expire back to neutral after this many blocks — the game's clock is block height, not wall-clock. Current height comes from mempool.space (BlockHeightService, 60s cache), same trust model as the BTC Map dependency. A background sweep actively reverts timed-out claims (tearing down their links, same as a normal recapture); claim/link routes also check inline so correctness doesn't depend on sweep timing between runs. - Pre-existing claims (already live in production) get backfilled with the real current block height once at startup rather than a guessed historical value — dry-run verified against an actual copy of the live production database before deploying. - Score now reports link count per team alongside places/fields/area (backend already computed this; only the frontend display was missing). 22 new tests (53 total): pure expiry-logic unit tests plus full claim/link/sweep integration tests using a network-free fake block-height provider (BlockHeightProvider interface + FakeBlockHeight test double). --- README.md | 17 ++- frontend/src/views/MapView.vue | 2 + server/src/app.test.ts | 105 ++++++++++++++++++- server/src/app.ts | 9 +- server/src/config.ts | 11 ++ server/src/db/migrations.ts | 10 ++ server/src/index.ts | 30 ++++++ server/src/routePrefix.test.ts | 3 +- server/src/routes/claims.ts | 23 ++-- server/src/routes/links.ts | 27 +++-- server/src/routes/places.ts | 4 +- server/src/services/blockHeight.ts | 31 ++++++ server/src/services/expiry.test.ts | 133 ++++++++++++++++++++++++ server/src/services/expiry.ts | 58 +++++++++++ server/src/testUtils/fakeBlockHeight.ts | 10 ++ server/src/types.ts | 1 + 16 files changed, 451 insertions(+), 23 deletions(-) create mode 100644 server/src/services/blockHeight.ts create mode 100644 server/src/services/expiry.test.ts create mode 100644 server/src/services/expiry.ts create mode 100644 server/src/testUtils/fakeBlockHeight.ts diff --git a/README.md b/README.md index 942a769..8ed0dfb 100644 --- a/README.md +++ b/README.md @@ -42,14 +42,27 @@ Map's public API. (default 40m) of it, via browser geolocation — no claiming from the couch. - Claiming an enemy-held place captures it for your team and tears down any links running through it. -- A link can be created between any two places your team currently holds, - as long as you're standing at the origin place — no maximum link distance. +- A link can be created between any two places your team currently holds, as + long as you're standing at the origin place, up to `MAX_LINK_DISTANCE_KM` + (default 500km) apart. Ingress scales its link range off portal/resonator + level (up to ~655km at max level); Regress has no leveling system to scale + off of, so this is a single flat cap instead. - A new link can't cross any existing link (either team) — same as Ingress. Links sharing an endpoint don't count as crossing. See `services/geo.ts#segmentsIntersect` and `routes/links.ts`. - Three mutually-linked, same-team places form a field (computed live, not stored) — see `server/src/services/fields.ts`. - Team choice is one-way once made. +- **Claims expire after `PORTAL_TIMEOUT_BLOCKS` Bitcoin blocks (default + 2100)** and revert to neutral, tearing down any links through them — the + game's clock is block height, not wall-clock time. Current height comes + from a public block explorer API (`BLOCK_HEIGHT_API_URL`, defaults to + mempool.space), same trust model as pulling place data from BTC Map. A + background sweep (`EXPIRY_SWEEP_INTERVAL_MS`, default 5 min) actively + reverts timed-out claims; claim/link requests also check inline so + correctness doesn't depend on sweep timing (`services/expiry.ts`). +- Score (`GET /api/score`) reports claimed places, **link count**, field + count, and total field area per team. ## Tests diff --git a/frontend/src/views/MapView.vue b/frontend/src/views/MapView.vue index 0ffe841..dfc6e75 100644 --- a/frontend/src/views/MapView.vue +++ b/frontend/src/views/MapView.vue @@ -182,11 +182,13 @@ watch(linkSourceId, redraw); REGRESS ▲ {{ orangeScore?.claimedPlaces ?? 0 }} nodes · + {{ orangeScore?.links ?? 0 }} links · {{ orangeScore?.fields ?? 0 }} fields · {{ (orangeScore?.areaKm2 ?? 0).toFixed(2) }} km² ▲ {{ greenScore?.claimedPlaces ?? 0 }} nodes · + {{ greenScore?.links ?? 0 }} links · {{ greenScore?.fields ?? 0 }} fields · {{ (greenScore?.areaKm2 ?? 0).toFixed(2) }} km² diff --git a/server/src/app.test.ts b/server/src/app.test.ts index 230c0cc..b2ca9db 100644 --- a/server/src/app.test.ts +++ b/server/src/app.test.ts @@ -6,6 +6,7 @@ import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure import type { FastifyInstance } from 'fastify'; import { buildApp } from './app.js'; import { loadConfig } from './config.js'; +import { FakeBlockHeight } from './testUtils/fakeBlockHeight.js'; // Three real Madeira BTC Map places, close enough together to link/field in tests. const PLACE_A = { id: 1128, lat: 32.6489863, lon: -16.9101835, name: 'Maia' }; @@ -24,6 +25,7 @@ let app: FastifyInstance; let dataDir: string; let cookieA: string; let cookieB: string; +let fakeBlockHeight: FakeBlockHeight; function nip98Header(sk: Uint8Array, url: string, method: string): string { const event = finalizeEvent( @@ -51,7 +53,8 @@ async function login(sk: Uint8Array): Promise { beforeAll(async () => { dataDir = mkdtempSync(join(tmpdir(), 'regress-test-')); const config = loadConfig({ DATA_DIR: dataDir, PUBLIC_URL: 'http://localhost:8096' } as NodeJS.ProcessEnv); - app = await buildApp({ config, dbPath: ':memory:', logger: false }); + fakeBlockHeight = new FakeBlockHeight(900_000); + app = await buildApp({ config, dbPath: ':memory:', logger: false, blockHeight: fakeBlockHeight }); // Seed places directly rather than hitting the real BTC Map API in tests. for (const p of [PLACE_A, PLACE_B, PLACE_C]) { @@ -273,3 +276,103 @@ describe('link crossing', () => { expect(res.statusCode).toBe(200); }); }); + +describe('max link distance', () => { + // 10 degrees of longitude at the equator is ~1,110km — comfortably over + // the 500km default cap, and far enough apart that haversineMeters leaves + // no ambiguity about which side of the limit this lands on. + const FAR_1 = { id: 90101, lat: 0, lon: 0 }; + const FAR_2 = { id: 90102, lat: 0, lon: 10 }; + + beforeAll(async () => { + for (const p of [FAR_1, FAR_2]) { + app.ctx.db + .prepare('INSERT INTO places (id, lat, lon, name, synced_at) VALUES (?, ?, ?, ?, ?)') + .run(p.id, p.lat, p.lon, `far-${p.id}`, Math.floor(Date.now() / 1000)); + await app.inject({ + method: 'POST', + url: `/api/places/${p.id}/claim`, + headers: { cookie: cookieA }, + payload: { lat: p.lat, lon: p.lon }, + }); + } + }); + + it('rejects a link longer than MAX_LINK_DISTANCE_KM', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/links', + headers: { cookie: cookieA }, + payload: { fromPlaceId: FAR_1.id, toPlaceId: FAR_2.id, lat: FAR_1.lat, lon: FAR_1.lon }, + }); + expect(res.statusCode).toBe(403); + expect(res.json().error).toMatch(/too long/); + }); +}); + +describe('claim expiry (block-height based)', () => { + const TIMEOUT_PLACE = { id: 90201, lat: 1, lon: 1, name: 'timeout-test' }; + + beforeAll(async () => { + app.ctx.db + .prepare('INSERT INTO places (id, lat, lon, name, synced_at) VALUES (?, ?, ?, ?, ?)') + .run(TIMEOUT_PLACE.id, TIMEOUT_PLACE.lat, TIMEOUT_PLACE.lon, TIMEOUT_PLACE.name, Math.floor(Date.now() / 1000)); + }); + + it('claims and stamps the current block height', async () => { + fakeBlockHeight.height = 950_000; + const res = await app.inject({ + method: 'POST', + url: `/api/places/${TIMEOUT_PLACE.id}/claim`, + headers: { cookie: cookieA }, + payload: { lat: TIMEOUT_PLACE.lat, lon: TIMEOUT_PLACE.lon }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().claimedAtBlock).toBe(950_000); + }); + + it('lets the same team re-claim once the portal has timed out (409 while live, 200 once expired)', async () => { + // Before timeout: orange re-claiming orange's own still-live claim is a 409 + // (this is the case that specifically distinguishes "expired" from "live" — + // recapture-by-the-enemy already works regardless of timeout and is covered + // by the earlier "lets the rival team capture it" test). + fakeBlockHeight.height = 950_000 + 2099; // one block short of expiry + const stillLive = await app.inject({ + method: 'POST', + url: `/api/places/${TIMEOUT_PLACE.id}/claim`, + headers: { cookie: cookieA }, + payload: { lat: TIMEOUT_PLACE.lat, lon: TIMEOUT_PLACE.lon }, + }); + expect(stillLive.statusCode).toBe(409); + + // Advance past the timeout (claimed at 950_000+2099, timeout is 2100 blocks). + fakeBlockHeight.height = 950_000 + 2099 + 2100; + const afterTimeout = await app.inject({ + method: 'POST', + url: `/api/places/${TIMEOUT_PLACE.id}/claim`, + headers: { cookie: cookieA }, + payload: { lat: TIMEOUT_PLACE.lat, lon: TIMEOUT_PLACE.lon }, + }); + expect(afterTimeout.statusCode).toBe(200); // no longer a 409 — the old claim had decayed to neutral + }); + + it('background sweep actually deletes expired claims and their links', async () => { + const { sweepExpiredClaims } = await import('./services/expiry.js'); + fakeBlockHeight.height = 960_000; + await app.inject({ + method: 'POST', + url: `/api/places/${TIMEOUT_PLACE.id}/claim`, + headers: { cookie: cookieA }, + payload: { lat: TIMEOUT_PLACE.lat, lon: TIMEOUT_PLACE.lon }, + }); + + let claim = app.ctx.db.prepare('SELECT * FROM claims WHERE place_id = ?').get(TIMEOUT_PLACE.id); + expect(claim).toBeDefined(); + + const swept = sweepExpiredClaims(app.ctx.db, 960_000 + 2100, 2100); + expect(swept.map((c) => c.placeId)).toContain(TIMEOUT_PLACE.id); + + claim = app.ctx.db.prepare('SELECT * FROM claims WHERE place_id = ?').get(TIMEOUT_PLACE.id); + expect(claim).toBeUndefined(); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 5be1e23..5d16ab3 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -6,6 +6,7 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import type { Config } from './config.js'; import { openDatabase, type DB } from './db/database.js'; +import { BlockHeightService, type BlockHeightProvider } from './services/blockHeight.js'; import nostrAuth from './plugins/nostr-auth.js'; import authRoutes from './routes/auth.js'; import placesRoutes from './routes/places.js'; @@ -17,6 +18,7 @@ import syncRoutes from './routes/sync.js'; export interface AppContext { config: Config; db: DB; + blockHeight: BlockHeightProvider; } declare module 'fastify' { @@ -29,6 +31,7 @@ export interface BuildAppOptions { config: Config; dbPath?: string; // override for tests (':memory:') logger?: boolean; + blockHeight?: BlockHeightProvider; // override for tests — avoids real network calls } export async function buildApp(opts: BuildAppOptions): Promise { @@ -37,7 +40,11 @@ export async function buildApp(opts: BuildAppOptions): Promise const app = Fastify({ logger: opts.logger ?? true, bodyLimit: 1 * 1024 * 1024 }); - const ctx: AppContext = { config, db }; + const ctx: AppContext = { + config, + db, + blockHeight: opts.blockHeight ?? new BlockHeightService(config.BLOCK_HEIGHT_API_URL), + }; app.decorate('ctx', ctx); await app.register(cookie); diff --git a/server/src/config.ts b/server/src/config.ts index 5235c79..7c0b107 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -21,6 +21,17 @@ const envSchema = z.object({ BTCMAP_RADIUS_KM: z.coerce.number().default(45), // How close (in meters) a player must be to a place to claim it or link from it. CLAIM_RADIUS_METERS: z.coerce.number().default(40), + // Maximum link length, in kilometers — mirrors Ingress's level-scaled link + // range (up to ~655km at max level) with a single flat cap instead, since + // Regress has no resonator-leveling system to scale it off of. + MAX_LINK_DISTANCE_KM: z.coerce.number().default(500), + // Claims expire after this many Bitcoin blocks and revert to neutral — + // mirrors Ingress's real-world portal decay, but block-height-based + // instead of wall-clock, since that's the native "clock" for this game. + PORTAL_TIMEOUT_BLOCKS: z.coerce.number().default(2100), + BLOCK_HEIGHT_API_URL: z.string().url().default('https://mempool.space/api/blocks/tip/height'), + // How often to sweep for and expire timed-out claims. + EXPIRY_SWEEP_INTERVAL_MS: z.coerce.number().default(5 * 60 * 1000), }); export type Config = z.infer; diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index ff662f5..2d977c3 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -70,4 +70,14 @@ CREATE INDEX idx_links_from ON links(from_place_id); CREATE INDEX idx_links_to ON links(to_place_id); `, }, + { + id: 2, + // Block height at claim time — the actual expiry clock (PORTAL_TIMEOUT_BLOCKS + // in config.ts), kept alongside the existing wall-clock claimed_at rather than + // replacing it, since that's still useful for display/audit. Nullable because + // existing rows predate this column; services/expiry.ts backfills them with + // the real current height once at server startup (see index.ts) rather than + // guessing a historical height from their wall-clock timestamp. + sql: `ALTER TABLE claims ADD COLUMN claimed_at_block INTEGER;`, + }, ]; diff --git a/server/src/index.ts b/server/src/index.ts index 10a57b5..08947f2 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,8 +1,38 @@ import { loadConfig } from './config.js'; import { buildApp } from './app.js'; +import { backfillMissingClaimedAtBlock, sweepExpiredClaims } from './services/expiry.js'; const config = loadConfig(); const app = await buildApp({ config }); +const { db, blockHeight } = app.ctx; + +try { + const startupHeight = await blockHeight.getCurrent(); + const backfilled = backfillMissingClaimedAtBlock(db, startupHeight); + if (backfilled > 0) { + app.log.info(`backfilled claimed_at_block for ${backfilled} pre-existing claim(s) at height ${startupHeight}`); + } +} catch (err) { + app.log.warn(`startup claimed_at_block backfill skipped: ${(err as Error).message}`); +} + +const sweepInterval = setInterval(() => { + void (async () => { + try { + const height = await blockHeight.getCurrent(); + const expired = sweepExpiredClaims(db, height, config.PORTAL_TIMEOUT_BLOCKS); + if (expired.length > 0) { + app.log.info(`expired ${expired.length} claim(s) at height ${height}: ${expired.map((c) => c.placeId).join(', ')}`); + } + } catch (err) { + app.log.warn(`expiry sweep failed: ${(err as Error).message}`); + } + })(); +}, config.EXPIRY_SWEEP_INTERVAL_MS); + +app.addHook('onClose', async () => { + clearInterval(sweepInterval); +}); try { await app.listen({ port: config.PORT, host: config.HOST }); diff --git a/server/src/routePrefix.test.ts b/server/src/routePrefix.test.ts index 86bd5a6..d469382 100644 --- a/server/src/routePrefix.test.ts +++ b/server/src/routePrefix.test.ts @@ -6,6 +6,7 @@ import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure import type { FastifyInstance } from 'fastify'; import { buildApp } from './app.js'; import { loadConfig } from './config.js'; +import { FakeBlockHeight } from './testUtils/fakeBlockHeight.js'; // Mirrors deploying under e.g. podsteadr.atobitcoin.io/regress/, where nginx // forwards the full prefixed path unchanged (not stripped) — see config.ts. @@ -35,7 +36,7 @@ beforeAll(async () => { PUBLIC_URL: 'https://podsteadr.atobitcoin.io', ROUTE_PREFIX: '/regress', } as NodeJS.ProcessEnv); - app = await buildApp({ config, dbPath: ':memory:', logger: false }); + app = await buildApp({ config, dbPath: ':memory:', logger: false, blockHeight: new FakeBlockHeight(900_000) }); }); afterAll(async () => { diff --git a/server/src/routes/claims.ts b/server/src/routes/claims.ts index 29a0cdc..59a9dc3 100644 --- a/server/src/routes/claims.ts +++ b/server/src/routes/claims.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { haversineMeters } from '../services/geo.js'; +import { isClaimExpired } from '../services/expiry.js'; import type { Claim, Place, User } from '../types.js'; const claimBody = z.object({ @@ -13,18 +14,19 @@ function nowSecs(): number { } export default async function claimsRoutes(app: FastifyInstance) { - const { db, config } = app.ctx; + const { db, config, blockHeight } = app.ctx; const getPlace = db.prepare('SELECT * FROM places WHERE id = ?'); const getUser = db.prepare('SELECT * FROM users WHERE pubkey = ?'); const getClaim = db.prepare('SELECT * FROM claims WHERE place_id = ?'); const deleteLinksTouching = db.prepare('DELETE FROM links WHERE from_place_id = ? OR to_place_id = ?'); const upsertClaim = db.prepare(` - INSERT INTO claims (place_id, team, claimed_by_pubkey, claimed_at) VALUES (?, ?, ?, ?) + INSERT INTO claims (place_id, team, claimed_by_pubkey, claimed_at, claimed_at_block) VALUES (?, ?, ?, ?, ?) ON CONFLICT(place_id) DO UPDATE SET team = excluded.team, claimed_by_pubkey = excluded.claimed_by_pubkey, - claimed_at = excluded.claimed_at + claimed_at = excluded.claimed_at, + claimed_at_block = excluded.claimed_at_block `); app.post('/api/places/:id/claim', { preHandler: app.requireAuth }, async (req, reply) => { @@ -45,19 +47,22 @@ export default async function claimsRoutes(app: FastifyInstance) { }); } + const currentHeight = await blockHeight.getCurrent(); const existing = getClaim.get(placeId) as Claim | undefined; - if (existing?.team === user.team) { + const expired = isClaimExpired(existing, currentHeight, config.PORTAL_TIMEOUT_BLOCKS); + if (existing && !expired && existing.team === user.team) { return reply.code(409).send({ error: 'already claimed by your team' }); } - // Recapturing (or freshly capturing a neutral place) tears down any links - // through it — a place can't stay part of an enemy field once it flips, - // and a neutral place never had links in the first place so this is a no-op then. + // Recapturing (or freshly capturing a neutral/timed-out place) tears down + // any links through it — a place can't stay part of a field once it + // flips or times out, and a genuinely neutral place never had links in + // the first place so this is a no-op then. db.transaction(() => { deleteLinksTouching.run(placeId, placeId); - upsertClaim.run(placeId, user.team, req.userPubkey, nowSecs()); + upsertClaim.run(placeId, user.team, req.userPubkey, nowSecs(), currentHeight); })(); - return { placeId, team: user.team, claimedBy: req.userPubkey }; + return { placeId, team: user.team, claimedBy: req.userPubkey, claimedAtBlock: currentHeight }; }); } diff --git a/server/src/routes/links.ts b/server/src/routes/links.ts index 978b91d..369ace0 100644 --- a/server/src/routes/links.ts +++ b/server/src/routes/links.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { haversineMeters, segmentsIntersect } from '../services/geo.js'; +import { isClaimExpired } from '../services/expiry.js'; import type { Claim, LinkRow, Place, User } from '../types.js'; const linkBody = z.object({ @@ -15,7 +16,7 @@ function nowSecs(): number { } export default async function linksRoutes(app: FastifyInstance) { - const { db, config } = app.ctx; + const { db, config, blockHeight } = app.ctx; const getPlace = db.prepare('SELECT * FROM places WHERE id = ?'); const getUser = db.prepare('SELECT * FROM users WHERE pubkey = ?'); @@ -43,19 +44,31 @@ export default async function linksRoutes(app: FastifyInstance) { const toPlace = getPlace.get(toPlaceId) as Place | undefined; if (!fromPlace || !toPlace) return reply.code(404).send({ error: 'place not found' }); + const currentHeight = await blockHeight.getCurrent(); const fromClaim = getClaim.get(fromPlaceId) as Claim | undefined; const toClaim = getClaim.get(toPlaceId) as Claim | undefined; - if (fromClaim?.team !== user.team || toClaim?.team !== user.team) { + const fromExpired = isClaimExpired(fromClaim, currentHeight, config.PORTAL_TIMEOUT_BLOCKS); + const toExpired = isClaimExpired(toClaim, currentHeight, config.PORTAL_TIMEOUT_BLOCKS); + if (fromExpired || toExpired || fromClaim?.team !== user.team || toClaim?.team !== user.team) { return reply.code(403).send({ error: 'both places must be claimed by your team' }); } // You must be physically at the origin portal to link out from it — same - // "hack a portal to get a key" presence requirement as Ingress. No range - // limit on the link's total length (per game design decision). - const distance = haversineMeters({ lat, lon }, fromPlace); - if (distance > config.CLAIM_RADIUS_METERS) { + // "hack a portal to get a key" presence requirement as Ingress. + const originDistance = haversineMeters({ lat, lon }, fromPlace); + if (originDistance > config.CLAIM_RADIUS_METERS) { return reply.code(403).send({ - error: `too far from the origin place: ${Math.round(distance)}m (must be within ${config.CLAIM_RADIUS_METERS}m)`, + error: `too far from the origin place: ${Math.round(originDistance)}m (must be within ${config.CLAIM_RADIUS_METERS}m)`, + }); + } + + // Flat cap on link length — Ingress scales this off portal/resonator + // level, which Regress has no equivalent of, so this is a single fixed + // ceiling instead (config.MAX_LINK_DISTANCE_KM). + const linkDistanceKm = haversineMeters(fromPlace, toPlace) / 1000; + if (linkDistanceKm > config.MAX_LINK_DISTANCE_KM) { + return reply.code(403).send({ + error: `link too long: ${linkDistanceKm.toFixed(1)}km (max ${config.MAX_LINK_DISTANCE_KM}km)`, }); } diff --git a/server/src/routes/places.ts b/server/src/routes/places.ts index 4524646..b8b8cca 100644 --- a/server/src/routes/places.ts +++ b/server/src/routes/places.ts @@ -4,13 +4,13 @@ export default async function placesRoutes(app: FastifyInstance) { const { db } = app.ctx; const listPlaces = db.prepare(` - SELECT p.*, c.team AS claim_team, c.claimed_by_pubkey, c.claimed_at + SELECT p.*, c.team AS claim_team, c.claimed_by_pubkey, c.claimed_at, c.claimed_at_block FROM places p LEFT JOIN claims c ON c.place_id = p.id ORDER BY p.id `); const getPlace = db.prepare(` - SELECT p.*, c.team AS claim_team, c.claimed_by_pubkey, c.claimed_at + SELECT p.*, c.team AS claim_team, c.claimed_by_pubkey, c.claimed_at, c.claimed_at_block FROM places p LEFT JOIN claims c ON c.place_id = p.id WHERE p.id = ? diff --git a/server/src/services/blockHeight.ts b/server/src/services/blockHeight.ts new file mode 100644 index 0000000..64bd9f7 --- /dev/null +++ b/server/src/services/blockHeight.ts @@ -0,0 +1,31 @@ +// Current Bitcoin block height — the game's expiry clock (see PORTAL_TIMEOUT_BLOCKS +// in config.ts). Pulled from a public block explorer API, same trust model as +// pulling place data from BTC Map: no local node dependency, no auth needed. + +const CACHE_TTL_MS = 60_000; + +/** Structural interface so tests can inject a fake without hitting the real network. */ +export interface BlockHeightProvider { + getCurrent(): Promise; +} + +export class BlockHeightService implements BlockHeightProvider { + private cached: { height: number; fetchedAt: number } | null = null; + + constructor(private apiUrl: string) {} + + async getCurrent(): Promise { + if (this.cached && Date.now() - this.cached.fetchedAt < CACHE_TTL_MS) { + return this.cached.height; + } + const res = await fetch(this.apiUrl); + if (!res.ok) throw new Error(`block height fetch failed: ${res.status} ${res.statusText}`); + const text = (await res.text()).trim(); + const height = Number(text); + if (!Number.isInteger(height) || height <= 0) { + throw new Error(`block height endpoint returned unexpected value: ${JSON.stringify(text)}`); + } + this.cached = { height, fetchedAt: Date.now() }; + return height; + } +} diff --git a/server/src/services/expiry.test.ts b/server/src/services/expiry.test.ts new file mode 100644 index 0000000..62b0ca5 --- /dev/null +++ b/server/src/services/expiry.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import { openDatabase } from '../db/database.js'; +import { backfillMissingClaimedAtBlock, findExpiredClaims, isClaimExpired, sweepExpiredClaims } from './expiry.js'; +import type { Claim } from '../types.js'; + +function seedPlace(db: ReturnType, id: number) { + db.prepare('INSERT INTO places (id, lat, lon, name, synced_at) VALUES (?, ?, ?, ?, ?)').run( + id, + 10, + 10 + id, + `place-${id}`, + 0, + ); +} + +function seedUser(db: ReturnType, pubkey: string, team: 'orange' | 'green') { + db.prepare('INSERT INTO users (pubkey, team, created_at, last_login_at) VALUES (?, ?, 0, 0)').run(pubkey, team); +} + +function seedClaim( + db: ReturnType, + placeId: number, + team: 'orange' | 'green', + pubkey: string, + claimedAtBlock: number | null, +) { + db.prepare( + 'INSERT INTO claims (place_id, team, claimed_by_pubkey, claimed_at, claimed_at_block) VALUES (?, ?, ?, 0, ?)', + ).run(placeId, team, pubkey, claimedAtBlock); +} + +describe('isClaimExpired', () => { + it('is false for undefined claim', () => { + expect(isClaimExpired(undefined, 900_100, 2100)).toBe(false); + }); + + it('is false when claimed_at_block is null (pre-migration row not yet backfilled)', () => { + const claim = { claimed_at_block: null } as Claim; + expect(isClaimExpired(claim, 900_100, 2100)).toBe(false); + }); + + it('is false just under the timeout', () => { + const claim = { claimed_at_block: 900_000 } as Claim; + expect(isClaimExpired(claim, 900_000 + 2099, 2100)).toBe(false); + }); + + it('is true exactly at the timeout', () => { + const claim = { claimed_at_block: 900_000 } as Claim; + expect(isClaimExpired(claim, 900_000 + 2100, 2100)).toBe(true); + }); + + it('is true well past the timeout', () => { + const claim = { claimed_at_block: 900_000 } as Claim; + expect(isClaimExpired(claim, 900_000 + 5000, 2100)).toBe(true); + }); +}); + +describe('findExpiredClaims / sweepExpiredClaims', () => { + it('finds and expires only claims past the timeout, tearing down their links', () => { + const db = openDatabase(':memory:'); + seedPlace(db, 1); + seedPlace(db, 2); + seedPlace(db, 3); + seedUser(db, 'pk1', 'orange'); + seedClaim(db, 1, 'orange', 'pk1', 900_000); // will be expired + seedClaim(db, 2, 'orange', 'pk1', 899_000); // long expired + seedClaim(db, 3, 'orange', 'pk1', 902_000); // still fresh + db.prepare( + 'INSERT INTO links (from_place_id, to_place_id, team, created_by_pubkey, created_at) VALUES (1, 2, ?, ?, 0)', + ).run('orange', 'pk1'); + + const currentHeight = 902_100; // 2100 blocks past place 1 and 2, not place 3 + const found = findExpiredClaims(db, currentHeight, 2100); + expect(found.map((c) => c.placeId).sort()).toEqual([1, 2]); + + const swept = sweepExpiredClaims(db, currentHeight, 2100); + expect(swept.map((c) => c.placeId).sort()).toEqual([1, 2]); + + const remainingClaims = db.prepare('SELECT place_id FROM claims').all() as { place_id: number }[]; + expect(remainingClaims.map((c) => c.place_id)).toEqual([3]); + + const remainingLinks = db.prepare('SELECT * FROM links').all(); + expect(remainingLinks).toHaveLength(0); // the 1-2 link died with either endpoint expiring + + db.close(); + }); + + it('is a no-op when nothing has expired', () => { + const db = openDatabase(':memory:'); + seedPlace(db, 1); + seedUser(db, 'pk1', 'orange'); + seedClaim(db, 1, 'orange', 'pk1', 900_000); + + expect(sweepExpiredClaims(db, 900_500, 2100)).toHaveLength(0); + expect(db.prepare('SELECT COUNT(*) c FROM claims').get()).toEqual({ c: 1 }); + + db.close(); + }); + + it('ignores claims with no claimed_at_block yet (not backfilled)', () => { + const db = openDatabase(':memory:'); + seedPlace(db, 1); + seedUser(db, 'pk1', 'orange'); + seedClaim(db, 1, 'orange', 'pk1', null); + + expect(sweepExpiredClaims(db, 999_999_999, 2100)).toHaveLength(0); + expect(db.prepare('SELECT COUNT(*) c FROM claims').get()).toEqual({ c: 1 }); + + db.close(); + }); +}); + +describe('backfillMissingClaimedAtBlock', () => { + it('sets claimed_at_block only for rows where it is currently null', () => { + const db = openDatabase(':memory:'); + seedPlace(db, 1); + seedPlace(db, 2); + seedUser(db, 'pk1', 'orange'); + seedClaim(db, 1, 'orange', 'pk1', null); + seedClaim(db, 2, 'orange', 'pk1', 500_000); // already has one — must not be overwritten + + const changed = backfillMissingClaimedAtBlock(db, 900_000); + expect(changed).toBe(1); + + const rows = db.prepare('SELECT place_id, claimed_at_block FROM claims ORDER BY place_id').all(); + expect(rows).toEqual([ + { place_id: 1, claimed_at_block: 900_000 }, + { place_id: 2, claimed_at_block: 500_000 }, + ]); + + db.close(); + }); +}); diff --git a/server/src/services/expiry.ts b/server/src/services/expiry.ts new file mode 100644 index 0000000..e6c548d --- /dev/null +++ b/server/src/services/expiry.ts @@ -0,0 +1,58 @@ +import type { DB } from '../db/database.js'; +import type { Claim } from '../types.js'; + +export interface ExpiredClaim { + placeId: number; + team: string; + claimedAtBlock: number; +} + +export function isClaimExpired( + claim: Claim | undefined, + currentHeight: number, + timeoutBlocks: number, +): boolean { + if (!claim || claim.claimed_at_block == null) return false; + return currentHeight - claim.claimed_at_block >= timeoutBlocks; +} + +export function findExpiredClaims(db: DB, currentHeight: number, timeoutBlocks: number): ExpiredClaim[] { + return db + .prepare( + `SELECT place_id AS placeId, team, claimed_at_block AS claimedAtBlock + FROM claims + WHERE claimed_at_block IS NOT NULL AND (? - claimed_at_block) >= ?`, + ) + .all(currentHeight, timeoutBlocks) as ExpiredClaim[]; +} + +/** + * Expires timed-out claims back to neutral — deletes the claim row and tears + * down any links through it, same as a normal recapture (see routes/claims.ts). + * Returns what was expired, for logging. + */ +export function sweepExpiredClaims(db: DB, currentHeight: number, timeoutBlocks: number): ExpiredClaim[] { + const expired = findExpiredClaims(db, currentHeight, timeoutBlocks); + if (expired.length === 0) return expired; + + const deleteLinksTouching = db.prepare('DELETE FROM links WHERE from_place_id = ? OR to_place_id = ?'); + const deleteClaim = db.prepare('DELETE FROM claims WHERE place_id = ?'); + db.transaction(() => { + for (const c of expired) { + deleteLinksTouching.run(c.placeId, c.placeId); + deleteClaim.run(c.placeId); + } + })(); + return expired; +} + +/** + * One-time backfill for claims that predate the claimed_at_block column — + * stamped with the real current height at the moment this runs (see + * index.ts), giving them a fresh full timeout window from server startup + * rather than guessing a historical height from their wall-clock timestamp. + */ +export function backfillMissingClaimedAtBlock(db: DB, currentHeight: number): number { + return db.prepare('UPDATE claims SET claimed_at_block = ? WHERE claimed_at_block IS NULL').run(currentHeight) + .changes; +} diff --git a/server/src/testUtils/fakeBlockHeight.ts b/server/src/testUtils/fakeBlockHeight.ts new file mode 100644 index 0000000..63f9f61 --- /dev/null +++ b/server/src/testUtils/fakeBlockHeight.ts @@ -0,0 +1,10 @@ +import type { BlockHeightProvider } from '../services/blockHeight.js'; + +/** Deterministic, network-free stand-in for tests. Mutable so tests can advance the "chain". */ +export class FakeBlockHeight implements BlockHeightProvider { + constructor(public height: number) {} + + async getCurrent(): Promise { + return this.height; + } +} diff --git a/server/src/types.ts b/server/src/types.ts index 628f982..39c164b 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -25,6 +25,7 @@ export interface Claim { team: Team; claimed_by_pubkey: string; claimed_at: number; + claimed_at_block: number | null; } export interface LinkRow {