diff --git a/apps/api/src/lib/idempotency/x402.ts b/apps/api/src/lib/idempotency/x402.ts
index 8ecb985..6b0b8bd 100644
--- a/apps/api/src/lib/idempotency/x402.ts
+++ b/apps/api/src/lib/idempotency/x402.ts
@@ -30,9 +30,7 @@ function buildPaidResponse(req: Request, result: QueryResult) {
payment: {
network: evidence?.network ?? config.STELLAR_NETWORK,
facilitatorUrl: evidence?.facilitatorUrl ?? config.X402_FACILITATOR_URL,
- evidence: evidence
- ? paymentEvidenceSummary(evidence)
- : { kind: "verified", status: "settlement-pending" }
+ evidence: evidence ? paymentEvidenceSummary(evidence) : undefined
},
result
};
diff --git a/apps/api/src/routes/protected.idempotency.test.ts b/apps/api/src/routes/protected.idempotency.test.ts
index d363824..6bf4f44 100644
--- a/apps/api/src/routes/protected.idempotency.test.ts
+++ b/apps/api/src/routes/protected.idempotency.test.ts
@@ -3,9 +3,9 @@ import express from "express";
import request from "supertest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
- applySponsorshipTestEnv,
- resetSponsorshipStore
-} from "../test/sponsorship-test-helpers.js";
+ applyApiTestEnv,
+ resetApiTestStorage
+} from "../test/api-test-helpers.js";
const executeQueryMock = vi.fn();
@@ -66,17 +66,19 @@ function demoPaidRequest(app: express.Express) {
}
describe("x402 idempotency", () => {
- let dbPath: string | undefined;
+ let analyticsDbPath: string | undefined;
+ let sponsorshipDbPath: string | undefined;
beforeEach(() => {
- dbPath = applySponsorshipTestEnv();
+ ({ analyticsDbPath, sponsorshipDbPath } = applyApiTestEnv());
executeQueryMock.mockReset();
mockQueryResult();
});
afterEach(async () => {
- await resetSponsorshipStore(dbPath);
- dbPath = undefined;
+ await resetApiTestStorage(analyticsDbPath, sponsorshipDbPath);
+ analyticsDbPath = undefined;
+ sponsorshipDbPath = undefined;
});
it("returns cached response for idempotent retries", async () => {
diff --git a/apps/web/package.json b/apps/web/package.json
index a770344..67be231 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -10,6 +10,7 @@
"build": "tsc -p tsconfig.json --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc -p tsconfig.json --noEmit",
+ "test": "node --import tsx --test src/components/PaymentEvidenceBanner.test.ts src/lib/wallet/machine.test.ts",
"lint": "eslint src vite.config.ts",
"format": "prettier --write src vite.config.ts tailwind.config.ts",
"format:check": "prettier --check src vite.config.ts tailwind.config.ts"
diff --git a/apps/web/src/components/PaymentEvidenceBanner.test.ts b/apps/web/src/components/PaymentEvidenceBanner.test.ts
new file mode 100644
index 0000000..d224002
--- /dev/null
+++ b/apps/web/src/components/PaymentEvidenceBanner.test.ts
@@ -0,0 +1,117 @@
+import { test, describe } from "node:test";
+import assert from "node:assert";
+import { getPaymentEvidenceInfo } from "./PaymentEvidenceBanner.js";
+import type { PaidQueryResponse } from "../types.js";
+
+describe("PaymentEvidenceBanner - getPaymentEvidenceInfo helper", () => {
+ test("handles missing (undefined) payment evidence", () => {
+ const info = getPaymentEvidenceInfo(undefined, "stellar:testnet");
+
+ assert.strictEqual(info.status, "missing");
+ assert.match(info.title, /Missing Payment Evidence/i);
+ assert.match(info.className, /--missing/);
+ assert.strictEqual(info.explorerUrl, undefined);
+ });
+
+ test("handles demo-mode payment evidence", () => {
+ const evidence: PaidQueryResponse["payment"]["evidence"] = {
+ kind: "demo",
+ status: "demo-paid",
+ network: "stellar:testnet",
+ payTo: "GBX...",
+ facilitatorUrl: "http://localhost:3001",
+ payer: "demo-agent"
+ };
+
+ const info = getPaymentEvidenceInfo(evidence, "stellar:testnet");
+
+ assert.strictEqual(info.status, "demo");
+ assert.match(info.title, /Demo Mode Payment/i);
+ assert.match(info.className, /--demo/);
+ assert.match(info.description, /demo-agent/);
+ assert.strictEqual(info.explorerUrl, undefined);
+ });
+
+ test("handles failed payment evidence", () => {
+ const evidence: PaidQueryResponse["payment"]["evidence"] = {
+ kind: "failed",
+ status: "failed",
+ network: "stellar:testnet",
+ payTo: "GBX...",
+ facilitatorUrl: "http://localhost:3001",
+ payer: "demo-agent",
+ error: "insufficient funds"
+ };
+
+ const info = getPaymentEvidenceInfo(evidence, "stellar:testnet");
+
+ assert.strictEqual(info.status, "failed");
+ assert.match(info.title, /Payment Verification Failed/i);
+ assert.match(info.className, /--failed/);
+ assert.match(info.description, /insufficient funds/);
+ assert.strictEqual(info.explorerUrl, undefined);
+ });
+
+ test("handles verified (challenge authorized, settlement pending) evidence on testnet", () => {
+ const evidence: PaidQueryResponse["payment"]["evidence"] = {
+ kind: "verified",
+ status: "verified",
+ network: "Test SDF Network ; September 2015",
+ payTo: "GBX...",
+ facilitatorUrl: "http://localhost:3001",
+ payer: "G_SPONSOR",
+ amount: "0.01",
+ asset: "USDC"
+ };
+
+ const info = getPaymentEvidenceInfo(evidence);
+
+ assert.strictEqual(info.status, "verified");
+ assert.match(info.title, /Payment Verified/i);
+ assert.match(info.className, /--verified/);
+ assert.match(info.description, /G_SPONSOR/);
+ assert.match(info.description, /USDC/);
+ assert.strictEqual(info.explorerUrl, undefined); // No Tx hash yet
+ });
+
+ test("handles settled payment evidence with explorer link on testnet", () => {
+ const evidence: PaidQueryResponse["payment"]["evidence"] = {
+ kind: "settled",
+ status: "settled",
+ network: "stellar:testnet",
+ payTo: "GBX...",
+ facilitatorUrl: "http://localhost:3001",
+ payer: "G_PAYER",
+ amount: "0.02",
+ asset: "USDC",
+ transactionHash: "abcd1234hash"
+ };
+
+ const info = getPaymentEvidenceInfo(evidence);
+
+ assert.strictEqual(info.status, "verified");
+ assert.match(info.title, /Payment Settled/i);
+ assert.match(info.className, /--verified/);
+ assert.match(info.description, /0.02 USDC/);
+ assert.strictEqual(info.explorerUrl, "https://stellar.expert/explorer/testnet/tx/abcd1234hash");
+ });
+
+ test("handles settled payment evidence with explorer link on mainnet", () => {
+ const evidence: PaidQueryResponse["payment"]["evidence"] = {
+ kind: "settled",
+ status: "settled",
+ network: "stellar:pubnet",
+ payTo: "GBX...",
+ facilitatorUrl: "http://localhost:3001",
+ payer: "G_PAYER",
+ amount: "0.02",
+ asset: "USDC",
+ transactionHash: "abcd5678hash"
+ };
+
+ const info = getPaymentEvidenceInfo(evidence);
+
+ assert.strictEqual(info.status, "verified");
+ assert.strictEqual(info.explorerUrl, "https://stellar.expert/explorer/public/tx/abcd5678hash");
+ });
+});
diff --git a/apps/web/src/components/PaymentEvidenceBanner.tsx b/apps/web/src/components/PaymentEvidenceBanner.tsx
new file mode 100644
index 0000000..fc7e499
--- /dev/null
+++ b/apps/web/src/components/PaymentEvidenceBanner.tsx
@@ -0,0 +1,119 @@
+import { AlertCircle, AlertTriangle, ExternalLink, ShieldCheck, HelpCircle } from "lucide-react";
+import type { PaidQueryResponse } from "../types.js";
+
+export interface EvidenceInfo {
+ status: "demo" | "verified" | "failed" | "missing";
+ title: string;
+ description: string;
+ explorerUrl?: string;
+ className: string;
+}
+
+export function getPaymentEvidenceInfo(
+ evidence?: PaidQueryResponse["payment"]["evidence"],
+ network?: string
+): EvidenceInfo {
+ if (!evidence) {
+ return {
+ status: "missing",
+ title: "Missing Payment Evidence",
+ description: "No verified payment evidence was found for this query execution. Intents should always settle via x402.",
+ className: "payment-banner--missing"
+ };
+ }
+
+ const net = (evidence.network || network || "").toLowerCase();
+ const isTestnet = net.includes("test") || net.includes("september 2015") || net.includes("testnet");
+ const explorerBase = isTestnet
+ ? "https://stellar.expert/explorer/testnet"
+ : "https://stellar.expert/explorer/public";
+
+ if (evidence.kind === "demo") {
+ return {
+ status: "demo",
+ title: "Demo Mode Payment",
+ description: `Simulated transaction proof without live credentials (Payer: ${evidence.payer || "demo-agent"}).`,
+ className: "payment-banner--demo"
+ };
+ }
+
+ if (evidence.kind === "failed") {
+ return {
+ status: "failed",
+ title: "Payment Verification Failed",
+ description: `The payment evidence is invalid: ${evidence.error || "unknown verification error"}.`,
+ className: "payment-banner--failed"
+ };
+ }
+
+ if (evidence.kind === "settled" || evidence.kind === "verified") {
+ const explorerUrl = evidence.transactionHash
+ ? `${explorerBase}/tx/${evidence.transactionHash}`
+ : undefined;
+
+ const sponsorText = evidence.payer ? ` (Payer: ${evidence.payer})` : "";
+ const amountText = evidence.amount && evidence.asset ? ` of ${evidence.amount} ${evidence.asset}` : "";
+
+ return {
+ status: "verified",
+ title: evidence.kind === "settled" ? "Payment Settled" : "Payment Verified",
+ description: evidence.kind === "settled"
+ ? `Successfully settled payment${amountText} on Stellar ${evidence.network}${sponsorText}.`
+ : `Authorized payment challenge${amountText} on Stellar ${evidence.network}${sponsorText} (settlement pending).`,
+ explorerUrl,
+ className: "payment-banner--verified"
+ };
+ }
+
+ return {
+ status: "missing",
+ title: "Unrecognized Payment Evidence",
+ description: "Unrecognized payment evidence format or invalid signature.",
+ className: "payment-banner--missing"
+ };
+}
+
+export interface PaymentEvidenceBannerProps {
+ payment: PaidQueryResponse["payment"];
+}
+
+export default function PaymentEvidenceBanner({ payment }: PaymentEvidenceBannerProps) {
+ const info = getPaymentEvidenceInfo(payment.evidence, payment.network);
+
+ const getIcon = () => {
+ switch (info.status) {
+ case "verified":
+ return
{info.description}
+Waiting for results. Start a query from the left panel.
) : ( <> +