From c441a14051d78e457603c41d1888cef0ea3ad662 Mon Sep 17 00:00:00 2001 From: laurastephaniee <331198591+laurastephaniee@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:12:24 +0100 Subject: [PATCH 1/2] feat(faucet): import withFileLock for claim file concurrency Add withFileLock import from json-store to enable proper file locking for faucet claims read-modify-write operations. This prepares the faucet route to prevent concurrent POST interleaving that currently bypasses json-store's file lock mechanism. --- app/api/faucet/route.ts | 63 ++++++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/app/api/faucet/route.ts b/app/api/faucet/route.ts index c64d61f2..be12d3c4 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, @@ -142,40 +143,44 @@ function claimsFilePath() { } 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 {} - } + }) } 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 { From 1f395e2e4c9ff7f18ee6674cf14d13c6315742b2 Mon Sep 17 00:00:00 2001 From: laurastephaniee <331198591+laurastephaniee@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:13:06 +0100 Subject: [PATCH 2/2] docs(faucet): document file locking pattern for claims RMW Add JSDoc comments explaining withFileLock usage in readClaims and writeClaims: - readClaims: Prevents concurrent RMW races when multiple POSTs target same wallet - writeClaims: Serializes writes via withFileLock queue to prevent partial/interleaved writes Documents the fix for the issue where raw readFile/writeFile bypassed json-store's withFileLock, allowing concurrent POSTs to interleave RMW operations on faucetClaims.json. The withFileLock wrapper uses per-file promise queues to preserve mutation order, ensuring atomic read-modify-write sequences even under concurrent serverless function invocations. --- app/api/faucet/route.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/api/faucet/route.ts b/app/api/faucet/route.ts index be12d3c4..5eee91d5 100644 --- a/app/api/faucet/route.ts +++ b/app/api/faucet/route.ts @@ -142,6 +142,10 @@ 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 { return withFileLock(claimsFilePath(), async () => { try { @@ -175,6 +179,10 @@ async function readClaims(): Promise { }) } +/** + * Write faucet claims with file-level locking to prevent partial writes. + * Serializes with concurrent reads/writes via withFileLock queue. + */ async function writeClaims(claims: FaucetClaims) { return withFileLock(claimsFilePath(), async () => { const file = claimsFilePath()