diff --git a/app/api/faucet/route.ts b/app/api/faucet/route.ts index c64d61f2..5eee91d5 100644 --- a/app/api/faucet/route.ts +++ b/app/api/faucet/route.ts @@ -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, @@ -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 { - try { - const raw = await readFile(claimsFilePath(), "utf8") - const parsed = JSON.parse(raw) as Record - 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 + 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 {