Skip to content
Open
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
4 changes: 1 addition & 3 deletions apps/api/src/lib/idempotency/x402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand Down
16 changes: 9 additions & 7 deletions apps/api/src/routes/protected.idempotency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 () => {
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
117 changes: 117 additions & 0 deletions apps/web/src/components/PaymentEvidenceBanner.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
119 changes: 119 additions & 0 deletions apps/web/src/components/PaymentEvidenceBanner.tsx
Original file line number Diff line number Diff line change
@@ -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 <ShieldCheck className="banner-icon icon-verified" size={20} />;
case "demo":
return <AlertTriangle className="banner-icon icon-demo" size={20} />;
case "failed":
return <AlertCircle className="banner-icon icon-failed" size={20} />;
case "missing":
default:
return <HelpCircle className="banner-icon icon-missing" size={20} />;
}
};

return (
<div className={`payment-banner ${info.className}`}>
<div className="payment-banner-main">
{getIcon()}
<div className="payment-banner-content">
<h4 className="payment-banner-title">{info.title}</h4>
<p className="payment-banner-desc">{info.description}</p>
</div>
</div>
{info.explorerUrl && (
<a
href={info.explorerUrl}
target="_blank"
rel="noreferrer"
className="payment-banner-link"
>
<span>Explorer</span>
<ExternalLink size={13} />
</a>
)}
</div>
);
}
3 changes: 3 additions & 0 deletions apps/web/src/pages/ControlDeckPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<QueryMode, string> = {
search: "Search",
Expand Down Expand Up @@ -499,6 +500,8 @@ export default function ControlDeckPage() {
<p className="empty-note">Waiting for results. Start a query from the left panel.</p>
) : (
<>
<PaymentEvidenceBanner payment={result.payment} />

<div className="result-meta">
<span>{result.result.providerName}</span>
<span>{money(result.result.priceUsd)}</span>
Expand Down
Loading