Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,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 as a signer: `POST /api/stellar/submit-signed` with the signed XDR and the same `auditEntryId`. The server checks that the XDR contains one native payment matching the unexpired `APPROVE`/`WARN` quote before broadcasting.
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
Expand Down
27 changes: 14 additions & 13 deletions src/app/api/decision/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,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,
Expand All @@ -177,18 +178,18 @@ export async function POST(request: NextRequest) {
riskFindings: decision.riskFindings.map(
(finding) => `${finding.code}: ${finding.detail}`,
),
...(finalDecision === "APPROVE" || finalDecision === "WARN"
? body.paymentQuoteInput
? {
paymentQuote: buildPaymentQuoteFromDecision({
destination: body.paymentQuoteInput.destination,
amountXLM: action.amountXLM,
memo: body.paymentQuoteInput.memo,
actionId: action.id,
network: body.paymentQuoteInput.network,
}),
}
: {}
...((finalDecision === "APPROVE" || finalDecision === "WARN") &&
(body.paymentQuote || body.paymentQuoteInput)
? {
paymentQuote: buildPaymentQuoteFromDecision({
destination: (body.paymentQuote || body.paymentQuoteInput)!.destination,
amountXLM: action.amountXLM,
memo: (body.paymentQuote || body.paymentQuoteInput)!.memo,
actionId: action.id,
network: (body.paymentQuote || body.paymentQuoteInput)!.network,
nowMs: decisionNowMs,
}),
}
: {}),
};

Expand Down
12 changes: 12 additions & 0 deletions src/app/api/stellar/build-payment/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
14 changes: 13 additions & 1 deletion src/app/api/stellar/build-payment/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { getProtectedPaymentFlowReadinessReport } from "@/lib/readiness/producti
import { consumeRateLimit, rateLimitHeaders } from "@/lib/security/rate-limit";
import { securityHeadersForRequest } from "@/lib/security/headers";
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";
Expand Down Expand Up @@ -129,6 +130,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,
Expand All @@ -138,6 +147,9 @@ export async function POST(request: NextRequest) {
sourcePublicKey,
xdr: unsigned.xdr,
networkPassphrase: unsigned.networkPassphrase,
decisionId: auditEntry!.id,
quoteExpiresAt,
buildAuthorization,
},
{ headers: { ...rateLimitHeaders(rate), ...securityHeadersForRequest(request) } },
);
Expand Down
81 changes: 76 additions & 5 deletions src/app/api/stellar/e2e-decision-build-submit.test.ts
Original file line number Diff line number Diff line change
@@ -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)}`;
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -126,7 +139,16 @@ beforeEach(async () => {
await resetAuditState(OPERATOR_USER_ID);
});

async function runOperatorFlow(decisionBody: Record<string, unknown>, paymentAmount: string) {
afterEach(() => {
vi.useRealTimers();
delete process.env.FORTEXA_PAYMENT_QUOTE_TTL_SECONDS;
});

async function runOperatorFlow(
decisionBody: Record<string, unknown>,
paymentAmount: string,
options: { beforeSubmit?: () => void | string | Promise<void | string>; decisionId?: string; expectedStatus?: number } = {},
) {
const decisionRes = await decisionPost(
jsonRequest("http://localhost/api/decision", {
...decisionBody,
Expand All @@ -141,7 +163,7 @@ async function runOperatorFlow(decisionBody: Record<string, unknown>, 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;
};

Expand All @@ -162,22 +184,29 @@ async function runOperatorFlow(decisionBody: Record<string, unknown>, 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,
auditEntryId: decisionPayload.auditEntry.id,
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 };
};
Expand All @@ -201,6 +230,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");
Expand Down Expand Up @@ -250,4 +281,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();
});
});
47 changes: 33 additions & 14 deletions src/app/api/stellar/submit-signed-idempotency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,19 +57,15 @@ vi.mock("@stellar/stellar-sdk", async () => {
};
});

vi.mock("@/lib/storage/audit-store", () => ({
getAuditEntryById: horizonMocks.getAuditEntry,
}));

vi.mock("@/lib/stellar/verify-payment-quote", async (importOriginal) => ({
...await importOriginal<typeof import("@/lib/stellar/verify-payment-quote")>(),
verifyPaymentAgainstQuote: horizonMocks.verifyQuote,
}));
vi.mock("@/lib/storage/audit-store", () => ({ getAuditEntryById: vi.fn() }));

import { Account, Asset, Keypair, Memo, Networks, Operation, TransactionBuilder } from "@stellar/stellar-sdk";
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 { resetRateLimitStore } from "@/lib/security/rate-limit";
import {
Expand All @@ -79,7 +75,8 @@ import {

const OPERATOR_USER_ID = "idem-operator-id";
const mockTxHash = "b".repeat(64);
const OTHER_TX_HASH = "d".repeat(64);
const decisionId = "00000000-0000-4000-8000-000000000001";
const quoteExpiresAt = new Date(Date.now() + 300_000).toISOString();

function horizonAccepted(hash = mockTxHash) {
return {
Expand All @@ -102,14 +99,26 @@ function operatorCookie() {
}

function submitRequest(body: unknown, extraHeaders: Record<string, string> = {}) {
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: {
"content-type": "application/json",
cookie: operatorCookie(),
...extraHeaders,
},
body: JSON.stringify({ auditEntryId: "00000000-0000-4000-8000-000000000000", ...(body as Record<string, unknown>) }),
body: JSON.stringify(authorizedBody),
});
}

Expand All @@ -135,9 +144,19 @@ function buildSignedXdr(amount: string, destination?: string) {
return tx.toXDR();
}

function slowSuccess(delayMs: number, hash = mockTxHash) {
return new Promise((resolve) => {
setTimeout(() => resolve(horizonAccepted(hash)), delayMs);
beforeEach(async () => {
vi.mocked(getAuditEntryById).mockResolvedValue({
id: decisionId,
timestamp: new Date().toISOString(),
decision: "APPROVE",
paymentQuote: { expiresAt: quoteExpiresAt },
} as Awaited<ReturnType<typeof getAuditEntryById>>);
horizonMocks.submitTransaction.mockReset();
horizonMocks.submitTransaction.mockResolvedValue({
hash: mockTxHash,
ledger: 42,
successful: true,
result_xdr: "AAAAAAAAAGQAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAA=",
});
horizonMocks.getAuditEntry.mockResolvedValue({
id: "00000000-0000-4000-8000-000000000000",
Expand Down
12 changes: 7 additions & 5 deletions src/app/api/stellar/submit-signed/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,14 @@ vi.mock("@/lib/storage/user-wallet-store", () => ({
getUserWallet: vi.fn(),
}));

vi.mock("@/lib/storage/audit-store", () => ({
getAuditEntryById: getAuditEntryMock,
vi.mock("@/lib/storage/audit-store", () => ({ getAuditEntryById: vi.fn(async () => ({ id: "decision-1" })) }));
vi.mock("@/lib/stellar/verify-payment-quote", async (importOriginal) => ({
...await importOriginal<typeof import("@/lib/stellar/verify-payment-quote")>(),
isPaymentDecisionCurrent: vi.fn(() => true),
}));

vi.mock("@/lib/stellar/verify-payment-quote", () => ({
verifyPaymentAgainstQuote: verifyQuoteMock,
vi.mock("@/lib/stellar/payment-build-authorization", async (importOriginal) => ({
...await importOriginal<typeof import("@/lib/stellar/payment-build-authorization")>(),
verifyBuildAuthorization: vi.fn(() => true),
}));

vi.mock("@/lib/validation/schemas", () => ({
Expand Down
Loading
Loading