Skip to content
Merged
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
71 changes: 42 additions & 29 deletions app/api/faucet/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 { mkdir, readFile, writeFile } from "node:fs/promises"
import path from "node:path"
import { serverDataJsonPath } from "@/lib/server-data-paths"
import { withFileLock } from "@/lib/json-store"
import {
classicLiqIssuerForStellarToml,
expectedClassicPhaserLiqSorobanContractId,
Expand Down Expand Up @@ -141,41 +142,53 @@ function claimsFilePath() {
return serverDataJsonPath("faucetClaims")
}

/**
* Read faucet claims with file-level locking to prevent concurrent RMW races.
* Wraps file I/O in withFileLock to serialize overlapping POSTs to same wallet.
*/
async function readClaims(): Promise<FaucetClaims> {
try {
const raw = await readFile(claimsFilePath(), "utf8")
const parsed = JSON.parse(raw) as Record<string, number | WalletClaims>
if (!parsed || typeof parsed !== "object") return {}
const normalized: FaucetClaims = {}
for (const [wallet, value] of Object.entries(parsed)) {
if (typeof value === "number") {
normalized[wallet] = { genesisAt: value }
continue
}
if (!value || typeof value !== "object") continue
const fp = value.faucetPending
let faucetPending: WalletClaims["faucetPending"]
if (fp && typeof fp === "object" && typeof fp.hash === "string" && typeof fp.at === "number") {
const r = await parseRewardType(fp.reward)
faucetPending = { hash: fp.hash, reward: r, at: fp.at }
}
normalized[wallet] = {
genesisAt: typeof value.genesisAt === "number" ? value.genesisAt : undefined,
dailyAt: typeof value.dailyAt === "number" ? value.dailyAt : undefined,
quests: value.quests && typeof value.quests === "object" ? value.quests : {},
faucetPending,
return withFileLock(claimsFilePath(), async () => {
try {
const raw = await readFile(claimsFilePath(), "utf8")
const parsed = JSON.parse(raw) as Record<string, number | WalletClaims>
if (!parsed || typeof parsed !== "object") return {}
const normalized: FaucetClaims = {}
for (const [wallet, value] of Object.entries(parsed)) {
if (typeof value === "number") {
normalized[wallet] = { genesisAt: value }
continue
}
if (!value || typeof value !== "object") continue
const fp = value.faucetPending
let faucetPending: WalletClaims["faucetPending"]
if (fp && typeof fp === "object" && typeof fp.hash === "string" && typeof fp.at === "number") {
const r = await parseRewardType(fp.reward)
faucetPending = { hash: fp.hash, reward: r, at: fp.at }
}
normalized[wallet] = {
genesisAt: typeof value.genesisAt === "number" ? value.genesisAt : undefined,
dailyAt: typeof value.dailyAt === "number" ? value.dailyAt : undefined,
quests: value.quests && typeof value.quests === "object" ? value.quests : {},
faucetPending,
}
}
return normalized
} catch {
return {}
}
return normalized
} catch {
return {}
}
})
}

/**
* Write faucet claims with file-level locking to prevent partial writes.
* Serializes with concurrent reads/writes via withFileLock queue.
*/
async function writeClaims(claims: FaucetClaims) {
const file = claimsFilePath()
await mkdir(path.dirname(file), { recursive: true })
await writeFile(file, JSON.stringify(claims, null, 2), "utf8")
return withFileLock(claimsFilePath(), async () => {
const file = claimsFilePath()
await mkdir(path.dirname(file), { recursive: true })
await writeFile(file, JSON.stringify(claims, null, 2), "utf8")
})
}

function faucetUsesDistributorTransfer(): boolean {
Expand Down