Skip to content
Merged
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
31 changes: 31 additions & 0 deletions app/api/cron/season/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server'
import { getAdminDb, getAdminFirestore } from '@/firebase-config'
import { prepareSeason, previousSeasonId, payoutCommands, prune } from '@/lib/server/seasonClose'
import { getCurrentSeasonId } from '@/lib/web3-client'
import { sweepPendingVaultPayouts } from '@/lib/server/vaultSweep'
import { recentWeekIdStrings } from '@/lib/vault-utils'

export const dynamic = 'force-dynamic'

Expand Down Expand Up @@ -79,11 +81,40 @@ export async function GET(req: NextRequest) {
console.error('[cron/season] prune failed (season still frozen):', err)
}

// ── Unpaid vault rewards ────────────────────────────────────────────────
// Rides the same daily wake-up, for the same reasons as the prune above,
// and for one more: it is the ONLY route to a stuck reward that does not
// ask the player to re-clear Bunker 5 and kill the final boss to reach the
// vault door again. The owner had to read the contract on celoscan to
// recover his; a player cannot. See lib/server/vaultSweep.ts.
//
// Caught, never thrown: a payout that cannot be retried must not stop a
// season being frozen.
let vault = { checked: 0, paid: 0, stillPending: 0 }
try {
const swept = await sweepPendingVaultPayouts(db, { weekIds: recentWeekIdStrings() })
vault = { checked: swept.checked, paid: swept.paid, stillPending: swept.stillPending }
if (swept.checked) {
console.log(
`[cron/season] vault sweep: ${swept.paid}/${swept.checked} paid`
+ (swept.stillPending
? ' · still pending: ' + swept.outcomes
.filter((o) => o.status === 'pending')
.map((o) => `${o.wallet}@${o.weekId} (${o.reason})`)
.join(' · ')
: ''),
)
}
} catch (err) {
console.error('[cron/season] vault sweep failed (season still frozen):', err)
}

return NextResponse.json({
seasonId,
created,
snapshot,
pruned: pruned.length,
vault,
commands: payoutCommands(snapshot),
note: created
? 'Season frozen. Run the commands above, then POST /api/season/status to mark it paid.'
Expand Down
22 changes: 2 additions & 20 deletions app/api/paper/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getAdminDb } from '@/firebase-config'
import { walletAddressSchema } from '@/lib/validation'
import { normalizeWalletAddress, getCurrentWeekIdString } from '@/lib/vault-utils'
import { ensureWeeklyCodeInDb } from '@/lib/server/vaultCode'

// Reads ?walletAddress, so it's always dynamic — declare it so the build
// doesn't try to prerender it statically and log a DYNAMIC_SERVER_USAGE error.
Expand Down Expand Up @@ -35,25 +36,6 @@ export const dynamic = 'force-dynamic'
// Response: { weekId, claimed: boolean, canClaim: boolean, code: string|null }
// =============================================

async function ensureWeeklyCode(db: NonNullable<ReturnType<typeof getAdminDb>>, weekId: string): Promise<string> {
const ref = db.ref(`vaultCodes/${weekId}`)
const snap = await ref.get()
const existing = snap.val()
if (existing && typeof existing.code === 'string' && /^\d{4}$/.test(existing.code)) {
return existing.code
}
// Not generated yet — atomic transaction so a race between two players'
// first-of-the-week requests can only ever commit one code.
const generated = String(Math.floor(1000 + Math.random() * 9000))
const txResult = await ref.transaction((current: unknown) => {
const cur = current as { code?: string } | null
if (cur && typeof cur.code === 'string' && /^\d{4}$/.test(cur.code)) return undefined // abort — already set
return { code: generated, generatedAt: Date.now() }
})
const finalVal = txResult.snapshot?.val() as { code?: string } | null
return finalVal?.code ?? generated
}

export async function GET(req: NextRequest) {
try {
const walletAddress = req.nextUrl.searchParams.get('walletAddress') ?? ''
Expand Down Expand Up @@ -81,7 +63,7 @@ export async function GET(req: NextRequest) {

let code: string | null = null
if (claimed) {
code = await ensureWeeklyCode(db, weekId)
code = await ensureWeeklyCodeInDb(db, weekId)
}

return NextResponse.json({ weekId, claimed, canClaim: !claimed, code }, { status: 200 })
Expand Down
86 changes: 86 additions & 0 deletions app/api/vault/prepare/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { NextResponse } from 'next/server'
import { getAdminDb } from '@/firebase-config'
import { getCurrentWeekIdString } from '@/lib/vault-utils'
import { ensureWeeklyCodeInDb } from '@/lib/server/vaultCode'
import { ensureWeeklyCodeOnChain, getVaultClients, isCodeOnChain } from '@/lib/server/vaultChain'

export const dynamic = 'force-dynamic'

// Storing a code is one write and normally finishes in a couple of seconds,
// but it waits for a Celo receipt and Vercel's 10s default is not a margin.
export const maxDuration = 30

// =============================================
// VAULT PREPARE — put this week's code on chain BEFORE anybody wins
// POST /api/vault/prepare -> { weekId, onChain, stored }
//
// PRIORITY #1 OF THE VAULT FIX, and the reason the other three are small.
//
// /api/vault/submit used to do two on-chain writes in one request the first
// time anyone won in a given week: store the week's code, then pay. Only the
// FIRST winner of the week took that path, which is why the failure looked
// random and survived for weeks — and the second write is the one that got
// dropped by a load-balanced Forno before it ever reached the mempool. Measured
// on chain for week 202632: "Store Weekly Vault Code" succeeded, "Submit Vault
// Code" was never broadcast at all. The pool was fine. Gas was fine. The code
// was set. The transaction simply did not exist.
//
// So the two writes are pulled apart in TIME rather than made more reliable.
// The engine calls this the moment the vault door is opened by anyone — hours
// or days before a code is solved — and by the time a player wins, the payout
// is a single write. The failure mode is not mitigated; the shape that produced
// it is gone.
//
// WHY IT NEEDS NO AUTH. It reveals nothing and grants nothing: the week's code
// is ALREADY public on chain (weeklyVaultCodes is a public getter on the
// deployed contract) and this route never returns it. Everything it can do,
// it can do at most once per week — after that `isCodeSetForWeek` is true and
// the call is one cheap RPC read. The worst an attacker achieves by hammering
// it is making our own server do a read it would have done anyway.
//
// The daily cron (/api/cron/season) calls the same helper as a net, so a week
// where nobody opens the vault door still has its code stored before the first
// winner appears.
// =============================================

export async function POST() {
const weekId = getCurrentWeekIdString()

try {
const db = getAdminDb()
if (!db) {
// Degrades OPEN, like every other weekly route here. With Firebase down
// there is no code to store, and refusing loudly would only turn a
// backend outage into a broken vault door.
return NextResponse.json({ weekId, onChain: false, stored: false, skipped: 'no_db' }, { status: 200 })
}

const clients = getVaultClients()
if (!clients) {
return NextResponse.json({ weekId, onChain: false, stored: false, skipped: 'not_configured' }, { status: 200 })
}

// Cheapest possible early exit, and the one taken on all but the first
// call of the week: one RPC read, no Firebase write, no transaction.
if (await isCodeOnChain(clients.publicClient, Number(weekId))) {
return NextResponse.json({ weekId, onChain: true, stored: false }, { status: 200 })
}

const code = await ensureWeeklyCodeInDb(db, weekId)
const result = await ensureWeeklyCodeOnChain(Number(weekId), code)

if (!result.onChain) {
console.error(`[vault/prepare] could not store week ${weekId} on chain:`, result.error ?? 'unknown')
}
return NextResponse.json(
{ weekId, onChain: result.onChain, stored: result.stored, txHash: result.txHash ?? null },
{ status: 200 },
)
} catch (error) {
// Never fails the caller. This is a background errand fired by a game
// screen; a player opening a vault door must not see an error because a
// preparation step they never asked for did not work.
console.error('[vault/prepare] error:', error instanceof Error ? error.message : error)
return NextResponse.json({ weekId, onChain: false, stored: false, skipped: 'error' }, { status: 200 })
}
}
Loading
Loading