Skip to content
38 changes: 22 additions & 16 deletions app/api/forge-agent/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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") {
Expand All @@ -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 } })
}
}
22 changes: 18 additions & 4 deletions app/api/x402/verify/route.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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
}

Expand All @@ -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,
Expand All @@ -43,4 +57,4 @@ export async function POST(request: NextRequest) {
} catch {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 })
}
}
}
261 changes: 261 additions & 0 deletions lib/phase-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1251,6 +1251,267 @@ export async function getTransactionResult(txHash: string): Promise<unknown> {
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<string, unknown>).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<string, () => unknown>
for (const version of ["v0", "v1", "v2"] as const) {
try {
const txMeta = anyMeta[version]?.() as Record<string, unknown> | 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<string, unknown>
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<string, unknown>, 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<string, unknown>
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<PhaseSettleVerificationResult> {
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
Expand Down
Loading