From 94fee59b96d7b1290e29e66d55e4125c01001eb3 Mon Sep 17 00:00:00 2001 From: Grantfox Agent <160465301+emirykl@users.noreply.github.com> Date: Wed, 30 Sep 2026 04:16:32 +0300 Subject: [PATCH] fix(stellar): reject expired or changed payment decisions at submit --- README.md | 2 +- src/app/api/decision/route.ts | 4 +- .../api/stellar/build-payment/route.test.ts | 12 +++ src/app/api/stellar/build-payment/route.ts | 14 +++- .../stellar/e2e-decision-build-submit.test.ts | 84 +++++++++++++++++-- .../stellar/submit-signed-idempotency.test.ts | 26 +++++- .../api/stellar/submit-signed/route.test.ts | 10 +++ src/app/api/stellar/submit-signed/route.ts | 47 +++++++++++ src/components/decision-console.tsx | 25 +++++- .../stellar/payment-build-authorization.ts | 43 ++++++++++ src/lib/stellar/verify-payment-quote.test.ts | 30 +++++-- src/lib/stellar/verify-payment-quote.ts | 30 ++++++- src/lib/types/domain.ts | 8 +- src/lib/validation/schemas.ts | 3 + 14 files changed, 316 insertions(+), 22 deletions(-) create mode 100644 src/lib/stellar/payment-build-authorization.ts diff --git a/README.md b/README.md index 6e608e7c..f97554d6 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ Simulation is strictly read-only: it never saves the policy and never consumes u 3. `Submit Signed XDR` orchestrates signing/submission path: - if signed input is already present → submit directly - if unsigned input is present → wallet signing is triggered first, then submit -4. Submit signed tx: `POST /api/stellar/submit-signed`. +4. Submit signed tx: `POST /api/stellar/submit-signed` with the build response's `decisionId`, `quoteExpiresAt`, and `buildAuthorization` alongside `signedXdr`. Submit reloads the decision and rejects an expired quote or a build token that does not match the signed transaction. 5. Explorer URL is returned and shown as clickable link. #### Quote-to-XDR trust boundary diff --git a/src/app/api/decision/route.ts b/src/app/api/decision/route.ts index 215e6290..54787317 100644 --- a/src/app/api/decision/route.ts +++ b/src/app/api/decision/route.ts @@ -109,9 +109,10 @@ export async function POST(request: NextRequest) { await consumeUsage(userId, action.amountXLM); } + const decisionNowMs = Date.now(); const auditEntry: AuditEntry = { id: randomUUID(), - timestamp: new Date().toISOString(), + timestamp: new Date(decisionNowMs).toISOString(), action, decision: finalDecision, explanation, @@ -130,6 +131,7 @@ export async function POST(request: NextRequest) { memo: (body.paymentQuote || body.paymentQuoteInput)!.memo, actionId: action.id, network: (body.paymentQuote || body.paymentQuoteInput)!.network, + nowMs: decisionNowMs, }), } : {}), diff --git a/src/app/api/stellar/build-payment/route.test.ts b/src/app/api/stellar/build-payment/route.test.ts index 39c8e61d..df57ead0 100644 --- a/src/app/api/stellar/build-payment/route.test.ts +++ b/src/app/api/stellar/build-payment/route.test.ts @@ -38,6 +38,18 @@ vi.mock("@stellar/stellar-sdk", async () => { }; }); +vi.mock("@/lib/decision/engine", () => ({ + evaluateDecision: vi.fn(async () => ({ + decision: "APPROVE", + explanation: "Fixture decision", + triggeredPolicies: [], + riskScore: 0, + riskFindings: [], + requiresManualApproval: false, + analyzerStatus: { isDegraded: false }, + })), +})); + import { Account } from "@stellar/stellar-sdk"; import { NextRequest } from "next/server"; diff --git a/src/app/api/stellar/build-payment/route.ts b/src/app/api/stellar/build-payment/route.ts index 6a977dc0..18c930aa 100644 --- a/src/app/api/stellar/build-payment/route.ts +++ b/src/app/api/stellar/build-payment/route.ts @@ -5,7 +5,8 @@ import { readJsonBody } from "@/lib/http/read-json-body"; import { getProtectedPaymentFlowReadinessReport } from "@/lib/readiness/production"; import { consumeRateLimit, rateLimitHeaders } from "@/lib/security/rate-limit"; import { buildUnsignedPaymentTransaction } from "@/lib/stellar/client"; -import { verifyPaymentAgainstQuote } from "@/lib/stellar/verify-payment-quote"; +import { createBuildAuthorization, getTransactionHash } from "@/lib/stellar/payment-build-authorization"; +import { getQuoteExpiresAt, verifyPaymentAgainstQuote } from "@/lib/stellar/verify-payment-quote"; import { getAuditEntryById } from "@/lib/storage/audit-store"; import { getUserWallet } from "@/lib/storage/user-wallet-store"; import { stellarBuildPaymentRequestSchema } from "@/lib/validation/schemas"; @@ -119,6 +120,14 @@ export async function POST(request: NextRequest) { sourcePublicKey, ); + const quoteExpiresAt = getQuoteExpiresAt(auditEntry!); + const buildAuthorization = createBuildAuthorization({ + userId, + decisionId: auditEntry!.id, + quoteExpiresAt, + transactionHash: getTransactionHash(unsigned.xdr, unsigned.networkPassphrase), + }); + return NextResponse.json( { ok: true, @@ -128,6 +137,9 @@ export async function POST(request: NextRequest) { sourcePublicKey, xdr: unsigned.xdr, networkPassphrase: unsigned.networkPassphrase, + decisionId: auditEntry!.id, + quoteExpiresAt, + buildAuthorization, }, { headers: rateLimitHeaders(rate) }, ); diff --git a/src/app/api/stellar/e2e-decision-build-submit.test.ts b/src/app/api/stellar/e2e-decision-build-submit.test.ts index d986db90..42458473 100644 --- a/src/app/api/stellar/e2e-decision-build-submit.test.ts +++ b/src/app/api/stellar/e2e-decision-build-submit.test.ts @@ -1,6 +1,6 @@ import { promises as fs } from "node:fs"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; vi.hoisted(() => { const tmpDir = `/tmp/fortexa-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}`; @@ -41,6 +41,19 @@ vi.mock("@stellar/stellar-sdk", async () => { }; }); +// Keep this fixture focused on the API handoff; the analyzer has separate tests. +vi.mock("@/lib/decision/engine", () => ({ + evaluateDecision: vi.fn(async (action: { amountXLM: number }) => ({ + decision: action.amountXLM === 95 ? "WARN" : "APPROVE", + explanation: "Fixture decision", + triggeredPolicies: [], + riskScore: 0, + riskFindings: [], + requiresManualApproval: false, + analyzerStatus: { isDegraded: false }, + })), +})); + import { Account, Keypair, Networks, TransactionBuilder } from "@stellar/stellar-sdk"; import { NextRequest } from "next/server"; @@ -125,7 +138,16 @@ beforeEach(async () => { await resetAuditState(OPERATOR_USER_ID); }); -async function runOperatorFlow(decisionBody: Record, paymentAmount: string) { +afterEach(() => { + vi.useRealTimers(); + delete process.env.FORTEXA_PAYMENT_QUOTE_TTL_SECONDS; +}); + +async function runOperatorFlow( + decisionBody: Record, + paymentAmount: string, + options: { beforeSubmit?: () => void | string | Promise; decisionId?: string; expectedStatus?: number } = {}, +) { const decisionRes = await decisionPost( jsonRequest("http://localhost/api/decision", { ...decisionBody, @@ -140,7 +162,7 @@ async function runOperatorFlow(decisionBody: Record, paymentAmo const decisionPayload = (await decisionRes.json()) as { result: { decision: string; riskScore: number }; - auditEntry: { id: string; decision: string }; + auditEntry: { id: string; decision: string; paymentQuote: { expiresAt: string } }; userId: string; }; @@ -161,19 +183,29 @@ async function runOperatorFlow(decisionBody: Record, paymentAmo xdr: string; networkPassphrase: string; sourcePublicKey: string; + decisionId: string; + quoteExpiresAt: string; + buildAuthorization: string; }; const unsignedTx = TransactionBuilder.fromXDR(buildPayload.xdr, buildPayload.networkPassphrase); unsignedTx.sign(sourceKeypair); const signedXdr = unsignedTx.toXDR(); + const replacementDecisionId = await options.beforeSubmit?.(); const submitRes = await submitSignedPost( - jsonRequest("http://localhost/api/stellar/submit-signed", { signedXdr }) + jsonRequest("http://localhost/api/stellar/submit-signed", { + signedXdr, + decisionId: options.decisionId ?? replacementDecisionId ?? buildPayload.decisionId, + quoteExpiresAt: buildPayload.quoteExpiresAt, + buildAuthorization: buildPayload.buildAuthorization, + }) ); - expect(submitRes.status).toBe(200); + expect(submitRes.status).toBe(options.expectedStatus ?? 200); const submitPayload = (await submitRes.json()) as { ok: boolean; + error?: string; explorerUrl: string; payment: { hash: string; ledger: number; status: string }; }; @@ -197,6 +229,8 @@ describe("E2E: decision → build XDR → submit (Horizon mocked)", () => { expect(auditEntries[0].id).toBe(decisionPayload.auditEntry.id); expect(buildPayload.ok).toBe(true); + expect(buildPayload.decisionId).toBe(decisionPayload.auditEntry.id); + expect(buildPayload.quoteExpiresAt).toBe(decisionPayload.auditEntry.paymentQuote.expiresAt); expect(buildPayload.networkPassphrase).toBe(Networks.TESTNET); expect(buildPayload.sourcePublicKey).toBe(sourceKeypair.publicKey()); expect(typeof buildPayload.xdr).toBe("string"); @@ -246,4 +280,44 @@ describe("E2E: decision → build XDR → submit (Horizon mocked)", () => { expect(horizonMocks.loadAccount).toHaveBeenCalled(); expect(horizonMocks.submitTransaction).toHaveBeenCalled(); }); + + it("rejects a quote that expires while the wallet is signing", async () => { + process.env.FORTEXA_PAYMENT_QUOTE_TTL_SECONDS = "1"; + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-09-30T12:00:00.000Z")); + const { submitPayload } = await runOperatorFlow( + { scenarioId: "safe-research-payment" }, + "18.0000000", + { beforeSubmit: () => { vi.setSystemTime(new Date("2026-09-30T12:00:01.000Z")); }, expectedStatus: 403 }, + ); + expect(submitPayload.error).toMatch(/quote|decision/i); + expect(horizonMocks.submitTransaction).not.toHaveBeenCalled(); + }); + + it("rejects a swapped decision id for the signed build", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-09-30T12:00:00.000Z")); + const { submitPayload } = await runOperatorFlow( + { scenarioId: "safe-research-payment" }, + "18.0000000", + { + beforeSubmit: async () => { + const secondDecision = await decisionPost(jsonRequest("http://localhost/api/decision", { + scenarioId: "safe-research-payment", + paymentQuoteInput: { + destination: destinationKeypair.publicKey(), + memo: "e2e-test", + network: "testnet", + }, + })); + expect(secondDecision.status).toBe(200); + const payload = (await secondDecision.json()) as { auditEntry: { id: string } }; + return payload.auditEntry.id; + }, + expectedStatus: 403, + }, + ); + expect(submitPayload.error).toMatch(/authorized payment build/i); + expect(horizonMocks.submitTransaction).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/stellar/submit-signed-idempotency.test.ts b/src/app/api/stellar/submit-signed-idempotency.test.ts index 7377693a..94376e65 100644 --- a/src/app/api/stellar/submit-signed-idempotency.test.ts +++ b/src/app/api/stellar/submit-signed-idempotency.test.ts @@ -33,10 +33,14 @@ vi.mock("@stellar/stellar-sdk", async () => { }; }); +vi.mock("@/lib/storage/audit-store", () => ({ getAuditEntryById: vi.fn() })); + import { Account, Asset, Keypair, Networks, Operation, TransactionBuilder } from "@stellar/stellar-sdk"; import { NextRequest } from "next/server"; import { POST as submitSignedPost } from "@/app/api/stellar/submit-signed/route"; +import { createBuildAuthorization, getTransactionHash } from "@/lib/stellar/payment-build-authorization"; +import { getAuditEntryById } from "@/lib/storage/audit-store"; import { IDEMPOTENCY_KEY_ERROR } from "@/lib/validation/schemas"; import { AUTH_COOKIE_KEY, createSessionToken } from "@/lib/auth/session"; import { resetSubmitIdempotencyState } from "@/lib/storage/submit-idempotency-store"; @@ -44,6 +48,8 @@ import { upsertUserWallet } from "@/lib/storage/user-wallet-store"; const OPERATOR_USER_ID = "idem-operator-id"; const mockTxHash = "b".repeat(64); +const decisionId = "00000000-0000-4000-8000-000000000001"; +const quoteExpiresAt = new Date(Date.now() + 300_000).toISOString(); // Fixed source wallet for this operator. The submit-signed route now verifies // that a signed XDR's source account matches the session wallet (issue #26), @@ -63,6 +69,18 @@ function operatorCookie() { } function submitRequest(body: unknown, extraHeaders: Record = {}) { + const payload = body as { signedXdr?: string }; + const authorizedBody = payload.signedXdr ? { + ...payload, + decisionId, + quoteExpiresAt, + buildAuthorization: createBuildAuthorization({ + userId: OPERATOR_USER_ID, + decisionId, + quoteExpiresAt, + transactionHash: getTransactionHash(payload.signedXdr, Networks.TESTNET), + }), + } : body; return new NextRequest("http://localhost/api/stellar/submit-signed", { method: "POST", headers: { @@ -70,7 +88,7 @@ function submitRequest(body: unknown, extraHeaders: Record = {}) cookie: operatorCookie(), ...extraHeaders, }, - body: JSON.stringify(body), + body: JSON.stringify(authorizedBody), }); } @@ -95,6 +113,12 @@ function buildSignedXdr(amount: string) { } beforeEach(async () => { + vi.mocked(getAuditEntryById).mockResolvedValue({ + id: decisionId, + timestamp: new Date().toISOString(), + decision: "APPROVE", + paymentQuote: { expiresAt: quoteExpiresAt }, + } as Awaited>); horizonMocks.submitTransaction.mockReset(); horizonMocks.submitTransaction.mockResolvedValue({ hash: mockTxHash, diff --git a/src/app/api/stellar/submit-signed/route.test.ts b/src/app/api/stellar/submit-signed/route.test.ts index 2145cd3f..8d675048 100644 --- a/src/app/api/stellar/submit-signed/route.test.ts +++ b/src/app/api/stellar/submit-signed/route.test.ts @@ -49,6 +49,16 @@ vi.mock("@/lib/storage/user-wallet-store", () => ({ getUserWallet: vi.fn(), })); +vi.mock("@/lib/storage/audit-store", () => ({ getAuditEntryById: vi.fn(async () => ({ id: "decision-1" })) })); +vi.mock("@/lib/stellar/verify-payment-quote", async (importOriginal) => ({ + ...await importOriginal(), + isPaymentDecisionCurrent: vi.fn(() => true), +})); +vi.mock("@/lib/stellar/payment-build-authorization", async (importOriginal) => ({ + ...await importOriginal(), + verifyBuildAuthorization: vi.fn(() => true), +})); + vi.mock("@/lib/validation/schemas", () => ({ stellarSubmitSignedRequestSchema: { safeParse: vi.fn(), diff --git a/src/app/api/stellar/submit-signed/route.ts b/src/app/api/stellar/submit-signed/route.ts index 664a2cf8..4cf9cdf5 100644 --- a/src/app/api/stellar/submit-signed/route.ts +++ b/src/app/api/stellar/submit-signed/route.ts @@ -8,7 +8,11 @@ import { recordStellarSubmitResult } from "@/lib/observability/metrics"; import { getProtectedPaymentFlowReadinessReport } from "@/lib/readiness/production"; import { consumeRateLimit, rateLimitHeaders } from "@/lib/security/rate-limit"; import { decodeSignedXdrSourceAccount, submitSignedTransactionXdr } from "@/lib/stellar/client"; +import { assertStellarNetworkConfig } from "@/lib/stellar/network-config"; import { getStellarExplorerTransactionUrl } from "@/lib/stellar/network"; +import { getTransactionHash, verifyBuildAuthorization } from "@/lib/stellar/payment-build-authorization"; +import { isPaymentDecisionCurrent } from "@/lib/stellar/verify-payment-quote"; +import { getAuditEntryById } from "@/lib/storage/audit-store"; import { getIdempotencyRecord, hashSignedXdr, @@ -241,6 +245,36 @@ export async function POST(request: NextRequest) { }); } + const auditEntry = await getAuditEntryById(userId, payload.decisionId); + if (!isPaymentDecisionCurrent(auditEntry, payload.decisionId, payload.quoteExpiresAt)) { + return jsonWithRequestContext(request, { + route: "/api/stellar/submit-signed", + startedAtMs, + status: 403, + body: { error: "Payment decision or quote is no longer valid. Please re-evaluate the action." }, + headers: rateLimitHeaders(rate), + }); + } + + const transactionHash = getTransactionHash( + payload.signedXdr, + assertStellarNetworkConfig().networkPassphrase, + ); + if (!verifyBuildAuthorization(payload.buildAuthorization, { + userId, + decisionId: payload.decisionId, + quoteExpiresAt: payload.quoteExpiresAt, + transactionHash, + })) { + return jsonWithRequestContext(request, { + route: "/api/stellar/submit-signed", + startedAtMs, + status: 403, + body: { error: "Signed transaction does not match the authorized payment build." }, + headers: rateLimitHeaders(rate), + }); + } + const headerKey = request.headers.get("idempotency-key")?.trim(); const bodyKey = payload.idempotencyKey?.trim(); const mergedIdempotencyKey = @@ -294,6 +328,19 @@ export async function POST(request: NextRequest) { } } + // The idempotency lookup can take time; check the persisted authorization + // again at the last point before the external submission. + const currentDecision = await getAuditEntryById(userId, payload.decisionId); + if (!isPaymentDecisionCurrent(currentDecision, payload.decisionId, payload.quoteExpiresAt)) { + return jsonWithRequestContext(request, { + route: "/api/stellar/submit-signed", + startedAtMs, + status: 403, + body: { error: "Payment decision or quote is no longer valid. Please re-evaluate the action." }, + headers: rateLimitHeaders(rate), + }); + } + const submitted = await submitSignedTransactionXdr(payload.signedXdr); logInfo("Signed transaction submitted", { diff --git a/src/components/decision-console.tsx b/src/components/decision-console.tsx index 205951dd..9571ca68 100644 --- a/src/components/decision-console.tsx +++ b/src/components/decision-console.tsx @@ -78,6 +78,9 @@ type BuildPaymentResponse = { xdr?: string; sourcePublicKey?: string; networkPassphrase?: string; + decisionId?: string; + quoteExpiresAt?: string; + buildAuthorization?: string; }; type AgentPlanResponse = { ok?: boolean; error?: string; action?: AgentAction }; @@ -100,6 +103,8 @@ export function DecisionConsole() { const [executeAmount, setExecuteAmount] = useState(""); const [decisionData, setDecisionData] = useState(null); const [authorizedAuditEntryId, setAuthorizedAuditEntryId] = useState(null); + const [paymentBuildAuthorization, setPaymentBuildAuthorization] = useState(null); + const [paymentQuoteExpiresAt, setPaymentQuoteExpiresAt] = useState(null); const [lastTxExplorerUrl, setLastTxExplorerUrl] = useState(null); const [loading, setLoading] = useState(false); const [message, setMessage] = useState(null); @@ -139,6 +144,8 @@ export function DecisionConsole() { const destinationPreview = destination.trim().toUpperCase(); function resetPreparedXdr() { + setPaymentBuildAuthorization(null); + setPaymentQuoteExpiresAt(null); setUnsignedXdr(""); setSignedXdrInput(""); setSourcePublicKey(""); @@ -233,6 +240,7 @@ export function DecisionConsole() { return; } + resetPreparedXdr(); setDecisionData(payload); setAuthorizedAuditEntryId(payload.auditEntry.id); setMessage("Decision recorded in audit trail."); @@ -340,12 +348,16 @@ export function DecisionConsole() { }); const buildPayload = (await buildResponse.json()) as BuildPaymentResponse; - if (!buildResponse.ok || buildPayload.error || !buildPayload.xdr) { + if (!buildResponse.ok || buildPayload.error || !buildPayload.xdr || + buildPayload.decisionId !== authorizedAuditEntryId || + !buildPayload.quoteExpiresAt || !buildPayload.buildAuthorization) { setMessage(buildPayload.error ?? "Failed to build XDR."); return; } setUnsignedXdr(buildPayload.xdr); + setPaymentBuildAuthorization(buildPayload.buildAuthorization); + setPaymentQuoteExpiresAt(buildPayload.quoteExpiresAt); setSignedXdrInput(buildPayload.xdr); setSourcePublicKey(buildPayload.sourcePublicKey ?? balancePayload.publicKey ?? ""); setNetworkPassphrase(buildPayload.networkPassphrase ?? "TESTNET"); @@ -388,6 +400,10 @@ export function DecisionConsole() { async function submitSignedXdr(signedXdrArg?: string) { if (!ensureOperator()) return; + if (!authorizedAuditEntryId || !paymentQuoteExpiresAt || !paymentBuildAuthorization) { + setMessage("Build a payment from a current decision before submitting."); + return; + } const signedXdr = (signedXdrArg ?? signedXdrInput).trim(); if (!signedXdr) return; @@ -402,7 +418,12 @@ export function DecisionConsole() { const submitResponse = await fetch("/api/stellar/submit-signed", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ signedXdr }), + body: JSON.stringify({ + signedXdr, + decisionId: authorizedAuditEntryId, + quoteExpiresAt: paymentQuoteExpiresAt, + buildAuthorization: paymentBuildAuthorization, + }), }); const submitPayload = (await submitResponse.json()) as { diff --git a/src/lib/stellar/payment-build-authorization.ts b/src/lib/stellar/payment-build-authorization.ts new file mode 100644 index 00000000..09d95f66 --- /dev/null +++ b/src/lib/stellar/payment-build-authorization.ts @@ -0,0 +1,43 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +import { TransactionBuilder } from "@stellar/stellar-sdk"; + +type BuildAuthorization = { + userId: string; + decisionId: string; + quoteExpiresAt: string; + transactionHash: string; +}; + +function sign(payload: string): string { + const secret = process.env.FORTEXA_AUTH_SECRET?.trim(); + if (!secret) throw new Error("FORTEXA_AUTH_SECRET is required for payment authorization."); + return createHmac("sha256", secret).update(`payment-build:${payload}`).digest("base64url"); +} + +export function getTransactionHash(xdr: string, networkPassphrase: string): string { + return TransactionBuilder.fromXDR(xdr, networkPassphrase).hash().toString("hex"); +} + +export function createBuildAuthorization(input: BuildAuthorization): string { + const payload = Buffer.from(JSON.stringify(input)).toString("base64url"); + return `${payload}.${sign(payload)}`; +} + +export function verifyBuildAuthorization(token: string, expected: BuildAuthorization): boolean { + const parts = token.split("."); + if (parts.length !== 2 || token.length > 2048) return false; + const [payload, signature] = parts; + const actual = Buffer.from(signature); + const wanted = Buffer.from(sign(payload)); + if (actual.length !== wanted.length || !timingSafeEqual(actual, wanted)) return false; + try { + const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as BuildAuthorization; + return decoded.userId === expected.userId && + decoded.decisionId === expected.decisionId && + decoded.quoteExpiresAt === expected.quoteExpiresAt && + decoded.transactionHash === expected.transactionHash; + } catch { + return false; + } +} diff --git a/src/lib/stellar/verify-payment-quote.test.ts b/src/lib/stellar/verify-payment-quote.test.ts index cb626adc..94649643 100644 --- a/src/lib/stellar/verify-payment-quote.test.ts +++ b/src/lib/stellar/verify-payment-quote.test.ts @@ -2,12 +2,20 @@ import { afterEach, describe, expect, it } from "vitest"; import { buildPaymentQuoteFromDecision, + isPaymentDecisionCurrent, normalizeAmountXLM, verifyPaymentAgainstQuote, } from "@/lib/stellar/verify-payment-quote"; import type { AuditEntry } from "@/lib/types/domain"; function mockAuditEntry(overrides: Partial = {}): AuditEntry { + const quote = buildPaymentQuoteFromDecision({ + destination: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + amountXLM: 10, + actionId: "act-1", + }); + // Older entries have no fixed expiry and use their audit timestamp. + if (overrides.timestamp && !overrides.paymentQuote) delete quote.expiresAt; return { id: "audit-1", timestamp: new Date().toISOString(), @@ -23,11 +31,7 @@ function mockAuditEntry(overrides: Partial = {}): AuditEntry { explanation: "Approved", triggeredPolicies: [], riskFindings: [], - paymentQuote: buildPaymentQuoteFromDecision({ - destination: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - amountXLM: 10, - actionId: "act-1", - }), + paymentQuote: quote, ...overrides, }; } @@ -50,6 +54,22 @@ describe("verifyPaymentAgainstQuote", () => { expect(result.ok).toBe(true); }); + it("uses the injected clock and fixed expiry at submit", () => { + const nowMs = Date.parse("2026-09-30T12:00:00.000Z"); + const entry = mockAuditEntry({ + paymentQuote: buildPaymentQuoteFromDecision({ + destination: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + amountXLM: 10, + actionId: "act-1", + nowMs, + }), + }); + const expiresAt = entry.paymentQuote!.expiresAt!; + expect(isPaymentDecisionCurrent(entry, entry.id, expiresAt, Date.parse(expiresAt) - 1)).toBe(true); + expect(isPaymentDecisionCurrent(entry, entry.id, expiresAt, Date.parse(expiresAt))).toBe(false); + expect(isPaymentDecisionCurrent(entry, "different-decision", expiresAt, nowMs)).toBe(false); + }); + it("rejects blocked decisions", () => { const entry = mockAuditEntry({ decision: "BLOCK", paymentQuote: undefined }); const result = verifyPaymentAgainstQuote(entry, { diff --git a/src/lib/stellar/verify-payment-quote.ts b/src/lib/stellar/verify-payment-quote.ts index 00380efd..c8e090cb 100644 --- a/src/lib/stellar/verify-payment-quote.ts +++ b/src/lib/stellar/verify-payment-quote.ts @@ -103,7 +103,7 @@ const DEFAULT_QUOTE_TTL_SECONDS = 300; * Reads FORTEXA_PAYMENT_QUOTE_TTL_SECONDS; falls back to 300 s when the * value is absent, non-numeric, or less than 1. */ -function getQuoteTtlMs(): number { +export function getQuoteTtlMs(): number { const parsed = Number( process.env.FORTEXA_PAYMENT_QUOTE_TTL_SECONDS ?? DEFAULT_QUOTE_TTL_SECONDS, ); @@ -139,6 +139,7 @@ export function buildPaymentQuoteFromDecision(input: { memo?: string; actionId: string; network?: StellarNetworkId; + nowMs?: number; }): PaymentQuote { return { destination: input.destination.trim().toUpperCase(), @@ -146,12 +147,34 @@ export function buildPaymentQuoteFromDecision(input: { asset: "native", memo: (input.memo ?? `fortexa:${input.actionId}`).slice(0, 28), network: input.network ?? "testnet", + expiresAt: new Date((input.nowMs ?? Date.now()) + getQuoteTtlMs()).toISOString(), }; } +export function getQuoteExpiresAt(auditEntry: AuditEntry): string { + if (auditEntry.paymentQuote?.expiresAt) return auditEntry.paymentQuote.expiresAt; + const legacyExpiryMs = Date.parse(auditEntry.timestamp) + getQuoteTtlMs(); + return Number.isFinite(legacyExpiryMs) ? new Date(legacyExpiryMs).toISOString() : ""; +} + +/** Rechecks the persisted decision and its original expiry at submission time. */ +export function isPaymentDecisionCurrent( + auditEntry: AuditEntry | undefined, + decisionId: string, + quoteExpiresAt: string, + nowMs = Date.now(), +): boolean { + if (!auditEntry?.paymentQuote || auditEntry.id !== decisionId || + !EXECUTABLE_DECISIONS.has(auditEntry.decision)) return false; + const storedExpiry = getQuoteExpiresAt(auditEntry); + const expiryMs = Date.parse(storedExpiry); + return storedExpiry === quoteExpiresAt && Number.isFinite(expiryMs) && nowMs < expiryMs; +} + export function verifyPaymentAgainstQuote( auditEntry: AuditEntry | undefined, request: PaymentBuildParams, + nowMs = Date.now(), ): VerifyPaymentQuoteResult { if (!auditEntry) { return { @@ -170,7 +193,7 @@ export function verifyPaymentAgainstQuote( } if (request.requestTimestampMs !== undefined) { - const skewResult = validateRequestTimestamp(request.requestTimestampMs); + const skewResult = validateRequestTimestamp(request.requestTimestampMs, { nowMs }); if (!skewResult.ok) { const reason = skewResult.code === "stale" @@ -187,7 +210,8 @@ export function verifyPaymentAgainstQuote( } } - if (Date.now() - Date.parse(auditEntry.timestamp) > getQuoteTtlMs()) { + const expiresAtMs = Date.parse(getQuoteExpiresAt(auditEntry)); + if (!Number.isFinite(expiresAtMs) || nowMs >= expiresAtMs) { return { ok: false, status: 403, diff --git a/src/lib/types/domain.ts b/src/lib/types/domain.ts index 9f944430..e23d0664 100644 --- a/src/lib/types/domain.ts +++ b/src/lib/types/domain.ts @@ -106,13 +106,15 @@ export type StellarNetworkId = "testnet"; export type StellarAssetId = "native"; /** Immutable payment parameters authorized by an APPROVE/WARN decision. */ -export interface PaymentQuote { +export interface PaymentQuote { destination: string; amountXLM: string; asset: StellarAssetId; memo: string; - network: StellarNetworkId; -} + network: StellarNetworkId; + /** Fixed at the time of the decision, rather than extended by later steps. */ + expiresAt?: string; +} export interface AuditEntry { id: string; diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index c8b81028..6bf6ccd8 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -109,6 +109,9 @@ export const stellarBuildPaymentRequestSchema = z.object({ export const stellarSubmitSignedRequestSchema = z.object({ signedXdr: z.string().min(20).max(120000), + decisionId: z.string().uuid(), + quoteExpiresAt: z.string().datetime(), + buildAuthorization: z.string().min(20).max(2048), idempotencyKey: idempotencyKeySchema.optional(), });