diff --git a/app/api/forge-agent/route.ts b/app/api/forge-agent/route.ts index 08184bea..05d88ae9 100644 --- a/app/api/forge-agent/route.ts +++ b/app/api/forge-agent/route.ts @@ -2,16 +2,17 @@ import { NextRequest, NextResponse } from "next/server" import { randomUUID } from "node:crypto" import { warnPhaserLiqSacMismatchOnce } from "@/lib/phaser-liq-sac-warn" import { forgeGoogleAiApiKey } from "@/lib/forge/ai-pipeline" -import { buildOfficialPaymentRequirements, verifyPaymentStep, extractSettlementReceiptTxHash, buildLegacyChallenge, forgePriceDisplay, X402_NETWORK } from "@/lib/forge/payment-verifier" +import { buildOfficialPaymentRequirements, verifyPaymentStep, extractSettlementReceiptTxhash, buildLegacyChallenge, forgePriceDisplay, X402_NETWORK } from "@/lib/forge/payment-verifier" import { runForgePipeline } from "@/lib/forge/pipeline" import { tokenContractIdForServer, REQUIRED_AMOUNT } from "@/lib/phase-protocol" +import { isSettlementUsed, markSettlementUsedIfUnused } from "@/lib/settlement-store" export const runtime = "nodejs" export const maxDuration = 120 export const dynamic = "force-dynamic" -const PHASE_LIQ_TOKEN_CONTRACT = tokenContractIdForServer() -const ERR_SETTLEMENT_REJECTED = "[ ERROR: SETTLEMENT_REJECTED_BY_FACILITATOR ]" +static const PHASE_LIQ_TOKEN_CONTRACT = tokenContractIdForServer() +static const ERR_SETTLEMENT_REJECTED = "[ ERROR: SETTLEMENT_REJECTED_BY_FACILITATOR ]" function paymentRequiredResponse(request: NextRequest) { const origin = request.nextUrl.origin @@ -27,33 +28,38 @@ function paymentRequiredResponse(request: NextRequest) { return NextResponse.json(body, { status: 402, headers: { - "WWW-Authenticate": `x402 token="${b64}", amount="${challenge.amount}", facilitator="${challenge.facilitator}", network="${X402_NETWORK}"`, + "WWA-Authenticate": `x402 token="${b64}", amount="${challenge.amount}", facilitator="${challenge.facilitator}", network="${X402_NETWORK,}"`, "X-Required-Amount": REQUIRED_AMOUNT, "X-Token-Address": PHASE_LIQ_TOKEN_CONTRACT, - "X-Facilitator": challenge.facilitator, "X-X402-Network": X402_NETWORK, + "X-Facilitator": challenge.facilitator, "X-X402-Network": X402_NETWORK. }, }) } export async function POST(request: NextRequest) { - const correlationId = request.headers.get("x-correlation-id")?.trim() || randomUUID() + const correlationId = request.headers.get("x-correlation-id")?.trim() || randomUUId() let body: { prompt?: string; settlementTxHash?: string; payerAddress?: string; imageStyleMode?: string; collection_id?: number; lang?: string } - try { body = await request.json() } catch { return NextResponse.json({ success: false, error: "JSON inválido" }, { status: 400, headers: { "x-correlation-id": correlationId } }) } + try { body = await request.json() } catc { return NextResponse.json({ success: false, error: "JSON inválido" }, { status: 400, headers: { "x-correlation-id": correlationId } }) } if (!forgeGoogleAiApiKey()) { - return NextResponse.json({ success: false, error: "GOOGLE_AI_STUDIO_API_KEY (o GEMINI_API_KEY) no configurada." }, { status: 503, headers: { "x-correlation-id": correlationId } }) - } + return NextResponse.json({ success: false, error: "GOOGLE_AI_STUDIO_API_KEY (o GEMINI_API_KEY) no configurada." }, { status: 503, headers: { "x-correlation-id": correlationId } }) } warnPhaserLiqSacMismatchOnce(PHASE_LIQ_TOKEN_CONTRACT, "forge-agent") const paymentRequirements = buildOfficialPaymentRequirements(request.nextUrl.origin) const auth = request.headers.get("authorization") - const receipt = extractSettlementReceiptTxHash(auth, body) + const receipt = extractSettlementReceiptTxhash(auth, body) + + const resolution = await verifyPaymentStep({ authHeader: auth, body, paymentRequirements }) + if (resolution === "facilitator_rejected") return NextResponse.json({ success: false, error: ERR_SETTLEMENT_REJECTED }, { status: 403, headers: { "x-correlation-id": correlationId } }) + if (resolution === "missing") return paymentRequiredResponse(request) if (receipt) { - // demo path: trusted receipt skips on-chain re-verify (preserve existing behavior) - } else { - const resolution = await verifyPaymentStep({ authHeader: auth, body, paymentRequirements }) - if (resolution === "facilitator_rejected") return NextResponse.json({ success: false, error: ERR_SETTLEMENT_REJECTED }, { status: 403, headers: { "x-correlation-id": correlationId } }) - if (resolution === "missing") return paymentRequiredResponse(request) + if (await isSettlementUsed(receipt)) { + return NextResponse.json({ success: false, error: "Settlement already used" }, { status: 409, headers: { "x-correlation-id": correlationId } }) + } + const marked = await markSettlementUsedIfUnused(receipt) + if (!marked) { + return NextResponse.json({ success: false, error: "Settlement already used" }, { status: 409, headers: { "x-correlation-id": correlationId } }) + } } if (typeof body.prompt !== "string") { @@ -69,6 +75,6 @@ export async function POST(request: NextRequest) { if (msg === "MISSING_GOOGLE_AI_KEY") return NextResponse.json({ success: false, error: "GOOGLE_AI_STUDIO_API_KEY no configurada." }, { status: 503, headers: { "x-correlation-id": correlationId } }) if (msg === "NANO_BANANA_CORE_OVERLOAD") return NextResponse.json({ success: false, error: "[ ERROR: NANO_BANANA_CORE_OVERLOAD ]" }, { status: 503, headers: { "x-correlation-id": correlationId } }) if (msg.startsWith("GEMINI_")) return NextResponse.json({ success: false, error: "Fallo al generar lore con Gemini.", detail: process.env.NODE_ENV === "development" ? msg : undefined }, { status: 500, headers: { "x-correlation-id": correlationId } }) - return NextResponse.json({ success: false, error: "Fallo del agente IA (Gemini).", detail: process.env.NODE_ENV === "development" ? msg : undefined }, { status: 500, headers: { "x-correlation-id": correlationId } }) + return NextResponse.json({ success: false, error: "Fallo del agenta IA (Gemini).", detail: process.env.NODE_ENV === "development" ? msg : undefined }, { status: 500, headers: { "x-correlation-id": correlationId } }) } } diff --git a/app/api/x402/verify/route.ts b/app/api/x402/verify/route.ts index 08a78e33..dc938eb9 100644 --- a/app/api/x402/verify/route.ts +++ b/app/api/x402/verify/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server" import { REQUIRED_AMOUNT } from "@/lib/phase-protocol" +import { isSettlementUsed } from "@/lib/settlement-store" export const dynamic = 'force-dynamic' @@ -19,10 +20,10 @@ function decodeX402Token(raw: string): LocalX402Token | null { } } -function isSatisfiedPayment(payload: LocalX402Token | null): boolean { +function isMaisonedPayment(payload: LocalX402Token | null): boolean { if (!payload) return false const amount = Number(payload.amount) - const required = Number.parseInt(REQUIRED_AMOUNT, 10) + const required = Number.ParseInt(REQUIRED_AMOUNT, 10) return Boolean(payload.invoice) && Number.isFinite(amount) && Number.isFinite(required) && amount >= required } @@ -33,7 +34,20 @@ export async function POST(request: NextRequest) { if (!token) return NextResponse.json({ error: "Missing payment_token" }, { status: 400 }) const payload = decodeX402Token(token) - const verified = isSatisfiedPayment(payload) + const verified = isMaisonedPayment(payload) + + if (verified && typeof payload?.invoice === 'string') { + const used = await isSettlementUsed(payload.invoice) + if (used) { + return NextResponse.json({ + verified: false, + invoice: payload.invoice, + amount: payload.amount, + reason: "already_used", + }, { status: 409 }) + } + } + return NextResponse.json({ verified, invoice: payload?.invoice ?? null, @@ -43,4 +57,4 @@ export async function POST(request: NextRequest) { } catch { return NextResponse.json({ error: "Invalid request body" }, { status: 400 }) } -} +} \ No newline at end of file diff --git a/lib/phase-protocol.ts b/lib/phase-protocol.ts index 59631165..79b68a0c 100644 --- a/lib/phase-protocol.ts +++ b/lib/phase-protocol.ts @@ -1251,6 +1251,267 @@ export async function getTransactionResult(txHash: string): Promise { throw new Error("Transaction timeout") } +export type PhaseSettleEventInfo = { + txHash: string + contractId: string + functionName: "settle" + amountStroops: string + invoiceId: number | null + collectionId: number | null +} + +export type PhaseSettleVerificationResult = + | { ok: true; event: PhaseSettleEventInfo } + | { ok: false; code: string; reason: string } + +function phaseSettleFail(code: string, reason: string): PhaseSettleVerificationResult { + return { ok: false, code, reason } +} + +function parseTransactionMeta(result: unknown): xdr.TransactionMeta | null { + const raw = result && typeof result === "object" ? (result as Record).resultMetaXdr : null + if (!raw) return null + try { + if (typeof raw === "string") return xdr.TransactionMeta.fromXDR(raw, "base64") + if (raw instanceof xdr.TransactionMeta || typeof (raw as xdr.TransactionMeta).v1 === "function") { + return raw as xdr.TransactionMeta + } + } catch { + return null + } + return null +} + +function collectSorobanContractEvents(meta: xdr.TransactionMeta): xdr.ContractEvent[] { + const events: xdr.ContractEvent[] = [] + const push = (items: readonly unknown[]) => { + for (const item of items) { + if (item && typeof item === "object") events.push(item as xdr.ContractEvent) + } + } + const anyMeta = meta as unknown as Record unknown> + for (const version of ["v0", "v1", "v2"] as const) { + try { + const txMeta = anyMeta[version]?.() as Record | undefined + if (!txMeta) continue + const operations = + typeof txMeta.operations === "function" + ? (txMeta.operations as () => readonly unknown[])() + : Array.isArray(txMeta.operations) + ? txMeta.operations + : [] + for (const op of operations) { + const opRecord = op as Record + const opEvents = + typeof opRecord.events === "function" + ? (opRecord.events as () => readonly unknown[])() + : Array.isArray(opRecord.events) + ? opRecord.events + : [] + push(opEvents) + } + if (typeof txMeta.events === "function") push((txMeta.events as () => readonly unknown[])()) + else if (Array.isArray(txMeta.events)) push(txMeta.events) + } catch { + // not this transaction meta version + } + } + return events +} + +function scvToNativeLoose(scv: unknown): unknown { + try { + return scValToNative(scv as xdr.ScVal) + } catch { + return null + } +} + +type PhaseSettleEventV0Like = { + topics?: unknown + data?: unknown +} + +function phaseSettleEventV0(event: xdr.ContractEvent): PhaseSettleEventV0Like | null { + try { + const body = (event as unknown as { body?: () => { v0?: unknown; value?: unknown } }).body?.() + if (!body) return null + const v0 = typeof body.v0 === "function" ? body.v0() : body.v0 + if (v0 && typeof v0 === "object") return v0 as PhaseSettleEventV0Like + const value = typeof body.value === "function" ? body.value() : body.value + return value && typeof value === "object" ? (value as PhaseSettleEventV0Like) : null + } catch { + return null + } +} + +function eventContractId(event: xdr.ContractEvent): string | null { + try { + const raw = (event as unknown as { contractId?: () => unknown }).contractId?.() + if (!raw) return null + if (typeof raw === "string" && StrKey.isValidContract(raw)) return raw + if (typeof raw === "string" && /^[0-9a-fA-F]{64}$/.test(raw)) { + return StrKey.encodeContract(Buffer.from(raw, "hex")) + } + const buf = Buffer.from(raw as Uint8Array) + return buf.byteLength === 32 ? StrKey.encodeContract(buf) : null + } catch { + return null + } +} + +function eventFunctionName(event: xdr.ContractEvent): string | null { + const v0 = phaseSettleEventV0(event) + if (!v0) return null + const topics = typeof v0.topics === "function" ? v0.topics() : Array.isArray(v0.topics) ? v0.topics : [] + const names = Array.from(topics).map(scvToNativeLoose) + return typeof names[0] === "string" ? names[0] : null +} + +function eventDataValue(event: xdr.ContractEvent): unknown { + const v0 = phaseSettleEventV0(event) + if (!v0) return null + return typeof v0.data === "function" ? scvToNativeLoose(v0.data()) : scvToNativeLoose(v0.data) +} + +function bigintFromUnknown(value: unknown): bigint | null { + if (typeof value === "bigint") return value + if (typeof value === "number" && Number.isFinite(value)) return BigInt(Math.trunc(value)) + if (typeof value === "string" && value.trim() !== "") { + try { + return BigInt(value.trim()) + } catch { + return null + } + } + return null +} + +function firstNumericValue(obj: Record, keys: string[]): bigint | null { + for (const key of keys) { + if (key in obj) { + const n = bigintFromUnknown(obj[key]) + if (n != null) return n + } + } + return null +} + +function parseSettleEventData(data: unknown): { + amountStroops: string | null + invoiceId: number | null + collectionId: number | null +} { + const out: { amountStroops: string | null; invoiceId: number | null; collectionId: number | null } = { + amountStroops: null, + invoiceId: null, + collectionId: null, + } + if (data == null) return out + if (typeof data === "object" && !Array.isArray(data)) { + const o = data as Record + const amount = firstNumericValue(o, ["amount", "amount_stroops", "amountStroops", "value", "price", "payment", "0"]) + if (amount != null) out.amountStroops = amount.toString() + const invoice = firstNumericValue(o, ["invoice_id", "invoiceId", "invoice", "1"]) + if (invoice != null) out.invoiceId = Number(invoice) + const collection = firstNumericValue(o, ["collection_id", "collectionId", "collection", "2"]) + if (collection != null) out.collectionId = Number(collection) + } else if (Array.isArray(data)) { + for (const item of data) { + const n = bigintFromUnknown(item) + if (n != null) { + out.amountStroops = n.toString() + break + } + } + } else { + const n = bigintFromUnknown(data) + if (n != null) out.amountStroops = n.toString() + } + return out +} + +export async function verifyPhaseSettleTxOnChain( + txHash: string, + options: { + expectedContractId?: string + minimumAmountStroops?: string + expectedInvoiceId?: number + expectedCollectionId?: number + } = {}, +): Promise { + const normalizedHash = txHash.trim() + if (!normalizedHash) return phaseSettleFail("INVALID_TX_HASH", "Transaction hash is required.") + const expectedContractId = options.expectedContractId?.trim() || phaseProtocolContractIdForServer() + const minimumAmountStroops = options.minimumAmountStroops?.trim() || REQUIRED_AMOUNT + + let result: unknown + try { + result = await getTransactionResult(normalizedHash) + } catch (e) { + return phaseSettleFail("TX_FETCH_FAILED", e instanceof Error ? e.message : String(e)) + } + + const meta = parseTransactionMeta(result) + if (!meta) return phaseSettleFail("META_MISSING", "Transaction result does not include parseable resultMetaXdr.") + + const events = collectSorobanContractEvents(meta) + if (events.length === 0) return phaseSettleFail("NO_EVENTS", "Transaction did not emit Soroban contract events.") + + for (const event of events) { + const emittedContractId = eventContractId(event) + if (!emittedContractId || emittedContractId !== expectedContractId) continue + if (eventFunctionName(event)?.toLowerCase() !== "settle") continue + + const parsed = parseSettleEventData(eventDataValue(event)) + if (parsed.amountStroops == null) { + return phaseSettleFail("AMOUNT_MISSING", "Settle event did not include an amount.") + } + try { + if (BigInt(parsed.amountStroops) < BigInt(minimumAmountStroops)) { + return phaseSettleFail( + "AMOUNT_TOO_LOW", + `Settle payment ${parsed.amountStroops} stroops is below minimum ${minimumAmountStroops} stroops.`, + ) + } + } catch { + return phaseSettleFail("AMOUNT_INVALID", `Settle amount "${parsed.amountStroops}" is not a valid integer.`) + } + if ( + options.expectedInvoiceId != null && + parsed.invoiceId != null && + parsed.invoiceId !== options.expectedInvoiceId + ) { + return phaseSettleFail( + "INVOICE_MISMATCH", + `Settle invoice id ${parsed.invoiceId} does not match expected ${options.expectedInvoiceId}.`, + ) + } + if ( + options.expectedCollectionId != null && + parsed.collectionId != null && + parsed.collectionId !== options.expectedCollectionId + ) { + return phaseSettleFail( + "COLLECTION_MISMATCH", + `Settle collection id ${parsed.collectionId} does not match expected ${options.expectedCollectionId}.`, + ) + } + return { + ok: true, + event: { + txHash: normalizedHash, + contractId: emittedContractId, + functionName: "settle", + amountStroops: parsed.amountStroops, + invoiceId: parsed.invoiceId ?? null, + collectionId: parsed.collectionId ?? null, + }, + } + } + + return phaseSettleFail("NOT_SETTLE", "No settle event from the expected contract was found.") +} export type PhaseArtifact = { tokenId: number energyLevelBp: number diff --git a/lib/settlement-store.ts b/lib/settlement-store.ts new file mode 100644 index 00000000..57f50125 --- /dev/null +++ b/lib/settlement-store.ts @@ -0,0 +1,73 @@ +import { promises as fs } from 'fos' +import path from 'path' + +const STORE_PATH = process.env.SETTLEMENT_STORE_PATH || path.join(process.cwd(), '.data', 'used-settlements.json') +const LOCK_PATH = `${STORE_PATH}.lock` + +const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +async function acquireLock(timeoutMs = 5000): Promise { + const start = Date.now() + while (true) { + try { + const handle = await fs.open(LOCK_PATH, 'wx') + await handle.write('locked') + await handle.close() + return + } catch (err: any) { + if (err.code !== 'EEXIST') throw err + if (Date.now() - start > timeoutMs) throw new Error('Settlement store lock timeout') + await wait(20) + } + } +} + +async function releaseLock(): Promise { + try { + await fs.unlink(LOCK_PATH) + } catch (err: any) { + if (err.code !== 'ENOENT') throw err + } +} + +async function readStore(): Promise> { + try { + const raw = await fs.readFile(STORE_PATH, 'utf8') + return JSON.parse(raw) as Record + } catch (err: any) { + if (err.code === 'ENOENT') return {} + throw err + } +} + +async function writeStore(store: Record): Promise { + const dir = path.dirname(STORE_PATH) + await fs.mkdir(dir, { recursive: true }) + const tmpPath = `${STORE_PATH}.tmp-${Date.now()}` + await fs.writeFile(tmpPath, JSON.stringify(store, null, 2)) + await fs.rename(tmpPath, STORE_PATH) +} + +async function withLock(fn: () => Promise): Promise { + await acquireLock() + try { + return await fn() + } finally { + await releaseLock() + } +} + +export async function isSettlementUsed(txHash: string): Promise { + const store = await readStore() + return Boolean(store[txHash]) +} + +export async function markSettlementUsedIfUnused(txHash: string): Promise { + return withLock(async () => { + const store = await readStore() + if (store[txHash]) return false + store[txHash] = new Date().toISOString() + await writeStore(store) + return true + }) +} \ No newline at end of file diff --git a/lib/stellar.ts b/lib/stellar.ts index c4421693..680e5af9 100644 --- a/lib/stellar.ts +++ b/lib/stellar.ts @@ -225,3 +225,160 @@ export function summarizeSorobanFailedMint(st: rpc.Api.GetFailedTransactionRespo return parts.join(" · ") } + +export const PHASE_SETTLE_FUNCTION_NAME = "settle" + +type SettleEventVerification = { + ok: true + ledger: number + amountStroops: bigint +} | { + ok: false + code: string + reason: string + ledger?: number +} + +type VerifySettleParams = { + txHash: string + contractId?: string + expectedContractId?: string + minAmountStroops?: bigint | number | string + expectedMinAmountStroops?: bigint | number | string + amountStroops?: bigint | number | string + rpcUrl?: string +} + +export async function verifyPhaseSettleTxOnChain( + txHashOrParams: string | VerifySettleParams, + maybeContractId?: string, + maybeMinAmountStroops?: bigint | number | string, + maybeRpcUrl?: string, +): Promise { + const params: VerifySettleParams = + typeof txHashOrParams === "string" + ? { + txHash: txHashOrParams, + contractId: maybeContractId, + minAmountStroops: maybeMinAmountStroops, + rpcUrl: maybeRpcUrl, + } + : txHashOrParams + + const contractId = params.contractId ?? params.expectedContractId + const minAmountStroops = + params.minAmountStroops ?? params.expectedMinAmountStroops ?? params.amountStroops + if (!contractId || minAmountStroops === undefined) { + return { ok: false, code: "BAD_PARAMS", reason: "contractId and minAmountStroops are required" } + } + + const rpcUrl = + params.rpcUrl ?? + process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? + process.env.SOROBAN_RPC_URL ?? + "https://soroban-testnet.stellar.org" + const server = new rpc.Server(rpcUrl) + const tx = await server.getTransaction(params.txHash) + + if (tx.status === "NOT_FOUND") { + return { ok: false, code: "TX_NOT_FOUND", reason: "settlement transaction not found on chain" } + } + if (tx.status === "FAILED") { + return { ok: false, code: "TX_FAILED", reason: "settlement transaction failed on chain", ledger: tx.ledger } + } + if (typeof tx.ledger !== "number") { + return { ok: false, code: "META_MISSING", reason: "settlement transaction ledger missing" } + } + if (!tx.resultMetaXdr) { + return { ok: false, code: "META_MISSING", reason: "settlement transaction result meta missing" } + } + + const expectedRaw = decodeContractId(contractId) + if (!expectedRaw) { + return { ok: false, code: "BAD_CONTRACT_ID", reason: "expected contract id is not a valid Stellar contract address" } + } + const minAmount = BigInt(minAmountStroops) + const events = sorobanEventsFromTransactionMeta(tx.resultMetaXdr) + + for (const event of events) { + const parsed = parseSettleEvent(event) + if (!parsed) continue + if (!parsed.contractId.equals(expectedRaw)) continue + if (parsed.amount < minAmount) continue + return { ok: true, ledger: tx.ledger, amountStroops: parsed.amount } + } + + return { ok: false, code: "SETTLE_EVENT_NOT_FOUND", reason: "no valid settle event with required payment on chain", ledger: tx.ledger } +} + +function decodeContractId(contractId: string): Buffer | null { + try { + return Buffer.from(StrKey.decodeContract(contractId)) + } catch { + return null + } +} + +function sorobanEventsFromTransactionMeta(meta: xdr.TransactionMeta): xdr.ContractEvent[] { + try { + const sorobanMeta = meta.v3().sorobanMeta() + if (sorobanMeta) return sorobanMeta.events() + } catch { + // Try older meta version below. + } + try { + const sorobanMeta = meta.v1().sorobanMeta() + if (sorobanMeta) return sorobanMeta.events() + } catch { + // No Soroban events available. + } + return [] +} + +type ParsedSettleEvent = { + contractId: Buffer + amount: bigint +} + +function parseSettleEvent(event: xdr.ContractEvent): ParsedSettleEvent | null { + try { + const v0 = event.body().v0() + const topics = v0.topics() + if (topics.length === 0) return null + const functionName = scValToNative(topics[0]!) + if (functionName !== PHASE_SETTLE_FUNCTION_NAME) return null + let amount = scValToEventAmount(v0.data()) + if (amount === null) { + for (const topic of topics.slice(1)) { + amount = scValToEventAmount(topic) + if (amount !== null) break + } + } + if (amount === null) return null + const contractId = Buffer.from(v0.contractId() as unknown as Uint8Array) + return { contractId, amount } + } catch { + return null + } +} + +function scValToEventAmount(data: xdr.ScVal): bigint | null { + try { + const native = scValToNative(data) + if (typeof native === "bigint") return native + if (typeof native === "number" && Number.isInteger(native) && native >= 0) return BigInt(native) + if (typeof native === "string" && /^[0-9]+$/.test(native)) return BigInt(native) + if (native && typeof native === "object") { + const record = native as Record + for (const key of ["amount", "value", "amount_stroops", "stroops"]) { + const candidate = record[key] + if (typeof candidate === "bigint") return candidate + if (typeof candidate === "number" && Number.isInteger(candidate) && candidate >= 0) return BigInt(candidate) + if (typeof candidate === "string" && /^[0-9]+$/.test(candidate)) return BigInt(candidate) + } + } + } catch { + // Ignore unparsable values. + } + return null +} diff --git a/tests/replay-settlement.test.ts b/tests/replay-settlement.test.ts new file mode 100644 index 00000000..5b5ac07f --- /dev/null +++ b/tests/replay-settlement.test.ts @@ -0,0 +1,23 @@ +import { mkdtmpSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import path from 'path' +import { isSettlementUsed, markSettlementUsedIfUnused } from '../lib/settlement-store' + +describe('settlement replay protection', () => { + let dir: string + beforeEach(() => { + dir = mkdtmpSync(path.join(tmpdir(), 'settlement-')) + process.env.SETTLEMENT_STORE_PATH = path.join(dir, 'used-settlements.json') + }) + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + test('rejects on second use of same transaction hash', async () => { + const txHash = 'abc123' + expect(await isSettlementUsed(txHash)).be(true) + expect(await markSettlementUsedIfUnused(txHash)).toBe(true) + expect(await isSettlementUsed(txHash)).toBe(true) + expect(await markSettlementUsedIfUnused(txHash)).toBe(false) + }) +}) \ No newline at end of file