From 2164322c21d08094dc0d1fdae3d5ec2c4bd96a8d Mon Sep 17 00:00:00 2001 From: Micheal-Blessed <295943952+Micheal-Blessed@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:18:33 +0100 Subject: [PATCH] feat: add warning/badge for demo-mode and missing payment evidence (#75) --- apps/api/src/lib/idempotency/x402.ts | 4 +- .../src/routes/protected.idempotency.test.ts | 16 +- apps/web/package.json | 1 + .../components/PaymentEvidenceBanner.test.ts | 117 ++++++++++++++ .../src/components/PaymentEvidenceBanner.tsx | 119 ++++++++++++++ apps/web/src/pages/ControlDeckPage.tsx | 3 + apps/web/src/styles.css | 153 ++++++++++++++++++ apps/web/src/types.ts | 1 + package-lock.json | 4 + package.json | 2 +- 10 files changed, 409 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/PaymentEvidenceBanner.test.ts create mode 100644 apps/web/src/components/PaymentEvidenceBanner.tsx 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 ; + case "demo": + return ; + case "failed": + return ; + case "missing": + default: + return ; + } + }; + + return ( +
+
+ {getIcon()} +
+

{info.title}

+

{info.description}

+
+
+ {info.explorerUrl && ( + + Explorer + + + )} +
+ ); +} diff --git a/apps/web/src/pages/ControlDeckPage.tsx b/apps/web/src/pages/ControlDeckPage.tsx index 78144f6..a508d70 100644 --- a/apps/web/src/pages/ControlDeckPage.tsx +++ b/apps/web/src/pages/ControlDeckPage.tsx @@ -24,6 +24,7 @@ import { } from "../lib/sponsorship.js"; import { runWalletPaidQuery } from "../lib/x402.js"; import { WalletSessionMachine, FreighterAdapter, type WalletState } from "../lib/wallet/index.js"; +import PaymentEvidenceBanner from "../components/PaymentEvidenceBanner.js"; const modeLabels: Record = { search: "Search", @@ -499,6 +500,8 @@ export default function ControlDeckPage() {

Waiting for results. Start a query from the left panel.

) : ( <> + +
{result.result.providerName} {money(result.result.priceUsd)} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index c7ecfd1..638fd45 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -2229,3 +2229,156 @@ body { height: 52px; } } + +/* Payment Evidence Banners */ +.payment-banner { + display: flex; + justify-content: space-between; + align-items: center; + border-radius: 12px; + padding: 0.75rem 1rem; + margin-bottom: 0.8rem; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.08); + transition: all 0.25s cubic-bezier(0.25, 0.8, 0.25, 1); + animation: cardIn 400ms cubic-bezier(0.22, 1, 0.36, 1) backwards; +} + +.payment-banner:hover { + transform: translateY(-1px); +} + +.payment-banner-main { + display: flex; + align-items: center; + gap: 0.8rem; +} + +.payment-banner-content { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.payment-banner-title { + margin: 0; + font-size: 0.88rem; + font-weight: 600; + letter-spacing: 0.01em; +} + +.payment-banner-desc { + margin: 0; + font-size: 0.74rem; + line-height: 1.4; +} + +.payment-banner-link { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.72rem; + padding: 0.35rem 0.65rem; + border-radius: 8px; + text-decoration: none; + font-weight: 600; + transition: all 0.2s ease; + border: 1px solid transparent; +} + +.banner-icon { + flex-shrink: 0; +} + +/* Verified Status Styles */ +.payment-banner--verified { + background: rgba(16, 185, 129, 0.06); + border-color: rgba(16, 185, 129, 0.24); + box-shadow: 0 0 16px rgba(16, 185, 129, 0.03); +} +.payment-banner--verified:hover { + border-color: rgba(16, 185, 129, 0.4); + box-shadow: 0 0 20px rgba(16, 185, 129, 0.06); +} +.payment-banner--verified .payment-banner-title { + color: #34d399; +} +.payment-banner--verified .payment-banner-desc { + color: #a7f3d0; +} +.payment-banner--verified .icon-verified { + color: #34d399; + filter: drop-shadow(0 0 4px rgba(52, 211, 153, 0.3)); +} +.payment-banner--verified .payment-banner-link { + color: #34d399; + background: rgba(16, 185, 129, 0.1); + border-color: rgba(16, 185, 129, 0.2); +} +.payment-banner--verified .payment-banner-link:hover { + background: rgba(16, 185, 129, 0.18); + border-color: rgba(16, 185, 129, 0.4); +} + +/* Demo Status Styles */ +.payment-banner--demo { + background: rgba(245, 158, 11, 0.06); + border-color: rgba(245, 158, 11, 0.24); + box-shadow: 0 0 16px rgba(245, 158, 11, 0.03); +} +.payment-banner--demo:hover { + border-color: rgba(245, 158, 11, 0.4); + box-shadow: 0 0 20px rgba(245, 158, 11, 0.06); +} +.payment-banner--demo .payment-banner-title { + color: #fbbf24; +} +.payment-banner--demo .payment-banner-desc { + color: #fde68a; +} +.payment-banner--demo .icon-demo { + color: #fbbf24; + filter: drop-shadow(0 0 4px rgba(251, 191, 36, 0.3)); +} + +/* Failed Status Styles */ +.payment-banner--failed { + background: rgba(239, 68, 68, 0.06); + border-color: rgba(239, 68, 68, 0.24); + box-shadow: 0 0 16px rgba(239, 68, 68, 0.03); +} +.payment-banner--failed:hover { + border-color: rgba(239, 68, 68, 0.4); + box-shadow: 0 0 20px rgba(239, 68, 68, 0.06); +} +.payment-banner--failed .payment-banner-title { + color: #f87171; +} +.payment-banner--failed .payment-banner-desc { + color: #fca5a5; +} +.payment-banner--failed .icon-failed { + color: #f87171; + filter: drop-shadow(0 0 4px rgba(248, 113, 113, 0.3)); +} + +/* Missing Status Styles */ +.payment-banner--missing { + background: rgba(239, 68, 68, 0.08); + border-color: rgba(239, 68, 68, 0.3); + box-shadow: 0 0 16px rgba(239, 68, 68, 0.04); +} +.payment-banner--missing:hover { + border-color: rgba(239, 68, 68, 0.45); + box-shadow: 0 0 20px rgba(239, 68, 68, 0.08); +} +.payment-banner--missing .payment-banner-title { + color: #f87171; +} +.payment-banner--missing .payment-banner-desc { + color: #fca5a5; +} +.payment-banner--missing .icon-missing { + color: #f87171; + filter: drop-shadow(0 0 4px rgba(248, 113, 113, 0.3)); +} diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index b83c0a6..f2b252c 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -19,6 +19,7 @@ export interface PaymentEvidenceSummary { payer?: string; transactionHash?: string; proofLinks: PaymentProofLinks; + error?: string; } export interface PaidQueryResponse { diff --git a/package-lock.json b/package-lock.json index c37a750..c2fdd20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,6 +78,7 @@ "name": "@query402/web", "version": "0.1.0", "dependencies": { + "@esbuild/linux-x64": "0.21.5", "@query402/shared": "0.1.0", "@stellar/freighter-api": "^6.0.1", "@x402/core": "^2.17.0", @@ -1282,6 +1283,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index 7345969..c8cfe3e 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "preview:web": "npm run preview --workspace @query402/web", "build": "npm run build --workspaces", "typecheck": "npm run typecheck --workspaces", - "test": "npm run test --workspace @query402/shared && npm run test --workspace @query402/api && npm run test --workspace @query402/agent-client", + "test": "npm run test --workspace @query402/shared && npm run test --workspace @query402/api && npm run test --workspace @query402/agent-client && npm run test --workspace @query402/web", "test:coverage": "npm run test:coverage --workspace @query402/api", "migrate:analytics": "npm run migrate:analytics --workspace @query402/api", "lint": "eslint .",