From 53a6a0eb11a31392b25c1dd57dbd66ad962257a0 Mon Sep 17 00:00:00 2001
From: Marvell69
Date: Sat, 28 Mar 2026 00:29:24 +0100
Subject: [PATCH 1/3] feat: support paying in XLM for USDC invoices via path
payments
---
.../src/routes/payments-path-quote.test.js | 161 ++++++++++++++++++
backend/src/routes/payments.js | 18 +-
frontend/messages/en.json | 5 +
frontend/messages/es.json | 5 +
frontend/messages/pt.json | 5 +
frontend/src/app/(public)/pay/[id]/page.tsx | 95 ++++++++---
frontend/src/lib/stellar.ts | 52 +++++-
frontend/src/lib/usePayment.ts | 8 +
8 files changed, 321 insertions(+), 28 deletions(-)
create mode 100644 backend/src/routes/payments-path-quote.test.js
diff --git a/backend/src/routes/payments-path-quote.test.js b/backend/src/routes/payments-path-quote.test.js
new file mode 100644
index 00000000..6aa1d037
--- /dev/null
+++ b/backend/src/routes/payments-path-quote.test.js
@@ -0,0 +1,161 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import createPaymentsRouter from "./payments.js";
+
+const { findStrictReceivePaths, supabaseFrom } = vi.hoisted(() => ({
+ findStrictReceivePaths: vi.fn(),
+ supabaseFrom: vi.fn(),
+}));
+
+vi.mock("../lib/stellar.js", () => ({
+ findStrictReceivePaths,
+ findMatchingPayment: vi.fn(),
+ createRefundTransaction: vi.fn(),
+}));
+
+vi.mock("../lib/supabase.js", () => ({
+ supabase: {
+ from: supabaseFrom,
+ },
+}));
+
+vi.mock("../lib/redis.js", () => ({
+ connectRedisClient: vi.fn(() => Promise.resolve({})),
+ getCachedPayment: vi.fn(),
+ setCachedPayment: vi.fn(),
+ invalidatePaymentCache: vi.fn(),
+}));
+
+function createSupabaseSelectMock(payment) {
+ const chain = {
+ select: vi.fn(() => chain),
+ eq: vi.fn(() => chain),
+ is: vi.fn(() => chain),
+ maybeSingle: vi.fn(async () => ({ data: payment, error: null })),
+ };
+
+ return chain;
+}
+
+function getPathPaymentQuoteHandler() {
+ const router = createPaymentsRouter();
+ const layer = router.stack.find(
+ (entry) => entry.route?.path === "/path-payment-quote/:id",
+ );
+
+ return layer.route.stack.at(-1).handle;
+}
+
+function createMockResponse() {
+ return {
+ statusCode: 200,
+ body: undefined,
+ status(code) {
+ this.statusCode = code;
+ return this;
+ },
+ json(payload) {
+ this.body = payload;
+ return this;
+ },
+ };
+}
+
+describe("GET /api/path-payment-quote/:id", () => {
+ const paymentId = "9f927a2c-02d4-4f76-914c-62cf44d9525e";
+ const sourceAccount =
+ "GBRPYHIL2C7Q7PGLUKSTPIY2KPJ7QMZ4ZWJHQ6GUSIW2LQAHOMK5N7BI";
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns an XLM quote for a USDC invoice", async () => {
+ supabaseFrom.mockReturnValue(
+ createSupabaseSelectMock({
+ id: paymentId,
+ amount: 25,
+ asset: "USDC",
+ asset_issuer: "GDQOE23W4QK6WQ4R3BVCUO3PRA4VJ7A3M7MRWWX4V67WJYQ7QXKJQ4KJ",
+ recipient: "GB6REFUNDTESTRECIPIENTQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ",
+ status: "pending",
+ }),
+ );
+
+ findStrictReceivePaths.mockResolvedValue({
+ source_amount: "60.1250000",
+ source_asset_code: "XLM",
+ source_asset_issuer: null,
+ destination_amount: "25",
+ path: [],
+ });
+
+ const handler = getPathPaymentQuoteHandler();
+ const res = createMockResponse();
+
+ await handler(
+ {
+ params: { id: paymentId },
+ query: {
+ source_asset: "XLM",
+ source_account: sourceAccount,
+ },
+ merchant: { id: "merchant-1" },
+ },
+ res,
+ vi.fn(),
+ );
+
+ expect(res.statusCode).toBe(200);
+ expect(res.body).toMatchObject({
+ source_asset: "XLM",
+ source_amount: "60.1250000",
+ send_max: "60.7262500",
+ destination_asset: "USDC",
+ destination_asset_issuer: "GDQOE23W4QK6WQ4R3BVCUO3PRA4VJ7A3M7MRWWX4V67WJYQ7QXKJQ4KJ",
+ destination_amount: "25",
+ path: [],
+ slippage: 0.01,
+ });
+ expect(findStrictReceivePaths).toHaveBeenCalledWith({
+ sourceAccount,
+ destAssetCode: "USDC",
+ destAssetIssuer: "GDQOE23W4QK6WQ4R3BVCUO3PRA4VJ7A3M7MRWWX4V67WJYQ7QXKJQ4KJ",
+ destAmount: "25",
+ sourceAssetCode: "XLM",
+ sourceAssetIssuer: null,
+ });
+ });
+
+ it("rejects a quote request when the source asset already matches the invoice asset", async () => {
+ supabaseFrom.mockReturnValue(
+ createSupabaseSelectMock({
+ id: paymentId,
+ amount: 10,
+ asset: "XLM",
+ asset_issuer: null,
+ recipient: "GB6REFUNDTESTRECIPIENTQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ",
+ status: "pending",
+ }),
+ );
+
+ const handler = getPathPaymentQuoteHandler();
+ const res = createMockResponse();
+
+ await handler(
+ {
+ params: { id: paymentId },
+ query: {
+ source_asset: "XLM",
+ source_account: sourceAccount,
+ },
+ merchant: { id: "merchant-1" },
+ },
+ res,
+ vi.fn(),
+ );
+
+ expect(res.statusCode).toBe(400);
+ expect(res.body.error).toContain("Use a direct payment");
+ expect(findStrictReceivePaths).not.toHaveBeenCalled();
+ });
+});
diff --git a/backend/src/routes/payments.js b/backend/src/routes/payments.js
index 224f31f2..33aa79aa 100644
--- a/backend/src/routes/payments.js
+++ b/backend/src/routes/payments.js
@@ -1081,12 +1081,29 @@ function createPaymentsRouter({
const { data, error } = await query
.eq("id", req.params.id)
+ .is("deleted_at", null)
.maybeSingle();
+ if (error) {
+ error.status = 500;
+ throw error;
+ }
+
if (!data) {
return res.status(404).json({ error: "Payment not found" });
}
+ const sameAsset =
+ sourceAsset.toUpperCase() === data.asset.toUpperCase() &&
+ sourceAssetIssuer === (data.asset_issuer || null);
+
+ if (sameAsset) {
+ return res.status(400).json({
+ error:
+ "Source asset is the same as destination asset. Use a direct payment.",
+ });
+ }
+
const quote = await findStrictReceivePaths({
sourceAccount,
destAssetCode: data.asset,
@@ -1332,4 +1349,3 @@ function createPaymentsRouter({
}
export default createPaymentsRouter;
-
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 49987e2b..4e5e0e27 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -209,6 +209,11 @@
"paymentSent": "Payment sent!",
"paymentFailed": "Payment failed. Please try again.",
"connectedVia": "Connected via {provider}",
+ "approximateCostLabel": "Approximate cost in XLM",
+ "approximateCostHelp": "Estimated amount to deliver {amount} {asset} to the merchant.",
+ "slippageBuffer": "{percent}% safety buffer included. Max send: {sendMax} {asset}",
+ "pathPaymentTogglePrefix": "Pay with",
+ "pathPaymentToggleSuffix": "instead",
"processing": "Processing...",
"payWith": "Pay with {provider}",
"payWithFallback": "Pay with Wallet",
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index ff30f540..4e5a2add 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -209,6 +209,11 @@
"paymentSent": "Pago enviado",
"paymentFailed": "El pago fallo. Intentalo de nuevo.",
"connectedVia": "Conectado mediante {provider}",
+ "approximateCostLabel": "Costo aproximado en XLM",
+ "approximateCostHelp": "Monto estimado para entregar {amount} {asset} al comercio.",
+ "slippageBuffer": "Incluye un margen de seguridad del {percent}%. Envio maximo: {sendMax} {asset}",
+ "pathPaymentTogglePrefix": "Pagar con",
+ "pathPaymentToggleSuffix": "en su lugar",
"processing": "Procesando...",
"payWith": "Pagar con {provider}",
"payWithFallback": "Pagar con billetera",
diff --git a/frontend/messages/pt.json b/frontend/messages/pt.json
index e0bb6350..97812ecb 100644
--- a/frontend/messages/pt.json
+++ b/frontend/messages/pt.json
@@ -209,6 +209,11 @@
"paymentSent": "Pagamento enviado",
"paymentFailed": "O pagamento falhou. Tente novamente.",
"connectedVia": "Conectado via {provider}",
+ "approximateCostLabel": "Custo aproximado em XLM",
+ "approximateCostHelp": "Valor estimado para entregar {amount} {asset} ao lojista.",
+ "slippageBuffer": "Inclui margem de seguranca de {percent}%. Envio maximo: {sendMax} {asset}",
+ "pathPaymentTogglePrefix": "Pagar com",
+ "pathPaymentToggleSuffix": "em vez disso",
"processing": "Processando...",
"payWith": "Pagar com {provider}",
"payWithFallback": "Pagar com carteira",
diff --git a/frontend/src/app/(public)/pay/[id]/page.tsx b/frontend/src/app/(public)/pay/[id]/page.tsx
index f5b1dae8..d2de3b5d 100644
--- a/frontend/src/app/(public)/pay/[id]/page.tsx
+++ b/frontend/src/app/(public)/pay/[id]/page.tsx
@@ -62,6 +62,18 @@ interface PaymentDetails {
branding_config?: BrandingConfig | null;
}
+interface PathQuote {
+ source_asset: string;
+ source_asset_issuer: string | null;
+ source_amount: string;
+ send_max: string;
+ destination_asset: string;
+ destination_asset_issuer: string | null;
+ destination_amount: string;
+ path: Array<{ asset_code: string; asset_issuer: string | null }>;
+ slippage: number;
+}
+
// ─── Branding defaults ───────────────────────────────────────────────────────
const DEFAULT_CHECKOUT_THEME: Required<
@@ -330,16 +342,7 @@ export default function PaymentPage() {
// Path payment state
const [usePathPayment, setUsePathPayment] = useState(false);
- const [pathQuote, setPathQuote] = useState<{
- source_asset: string;
- source_asset_issuer: string | null;
- source_amount: string;
- send_max: string;
- destination_asset: string;
- destination_amount: string;
- path: Array<{ asset_code: string; asset_issuer: string | null }>;
- slippage: number;
- } | null>(null);
+ const [pathQuote, setPathQuote] = useState(null);
const [pathQuoteLoading, setPathQuoteLoading] = useState(false);
const [pathQuoteError, setPathQuoteError] = useState(null);
@@ -403,7 +406,13 @@ export default function PaymentPage() {
// ── Fetch path payment quote when wallet is connected ────────────────────
useEffect(() => {
- if (!payment || !activeProvider || payment.status !== "pending") return;
+ if (!payment || !activeProvider || payment.status !== "pending") {
+ setPathQuote(null);
+ setPathQuoteError(null);
+ setPathQuoteLoading(false);
+ setUsePathPayment(false);
+ return;
+ }
let cancelled = false;
(async () => {
@@ -420,14 +429,23 @@ export default function PaymentPage() {
`${API_URL}/api/path-payment-quote/${paymentId}?${qs}`
);
if (!res.ok) {
- setPathQuote(null);
+ if (!cancelled) {
+ setPathQuote(null);
+ setUsePathPayment(false);
+ }
return;
}
- const data = await res.json();
- if (!cancelled) setPathQuote(data);
+ const data = (await res.json()) as PathQuote;
+ if (!cancelled) {
+ setPathQuote(data);
+ setUsePathPayment(true);
+ }
} catch {
- if (!cancelled)
+ if (!cancelled) {
+ setPathQuote(null);
+ setUsePathPayment(false);
setPathQuoteError("Could not fetch path payment quote.");
+ }
} finally {
if (!cancelled) setPathQuoteLoading(false);
}
@@ -450,11 +468,13 @@ export default function PaymentPage() {
recipient: payment.recipient,
destAmount: pathQuote.destination_amount,
destAssetCode: pathQuote.destination_asset,
- destAssetIssuer: payment.asset_issuer,
+ destAssetIssuer: pathQuote.destination_asset_issuer,
sendMax: pathQuote.send_max,
sendAssetCode: pathQuote.source_asset,
sendAssetIssuer: pathQuote.source_asset_issuer,
path: pathQuote.path,
+ memo: payment.memo,
+ memoType: payment.memo_type,
});
} else {
result = await processPayment({
@@ -462,6 +482,8 @@ export default function PaymentPage() {
amount: String(payment.amount),
assetCode: payment.asset,
assetIssuer: payment.asset_issuer,
+ memo: payment.memo,
+ memoType: payment.memo_type,
});
}
@@ -670,6 +692,41 @@ export default function PaymentPage() {
})}
+ {pathQuote && !pathQuoteLoading && (
+
+
+ {t("approximateCostLabel")}
+
+
+
+
+ {Number(pathQuote.source_amount).toLocaleString(
+ locale,
+ {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 7,
+ }
+ )}{" "}
+ {pathQuote.source_asset}
+
+
+ {t("approximateCostHelp", {
+ amount: pathQuote.destination_amount,
+ asset: pathQuote.destination_asset,
+ })}
+
+
+
+ {t("slippageBuffer", {
+ percent: Math.round(pathQuote.slippage * 100),
+ sendMax: pathQuote.send_max,
+ asset: pathQuote.source_asset,
+ })}
+
+
+
+ )}
+
{/* Path payment toggle */}
{pathQuote && !pathQuoteLoading && (
)}
diff --git a/frontend/src/lib/stellar.ts b/frontend/src/lib/stellar.ts
index b6f5227e..62d9eac6 100644
--- a/frontend/src/lib/stellar.ts
+++ b/frontend/src/lib/stellar.ts
@@ -6,6 +6,8 @@ export interface PaymentTransactionParams {
amount: string;
assetCode: string;
assetIssuer: string | null;
+ memo?: string | null;
+ memoType?: string | null;
horizonUrl: string;
networkPassphrase: string;
}
@@ -20,6 +22,8 @@ export interface PathPaymentTransactionParams {
destAssetCode: string;
destAssetIssuer: string | null;
path: Array<{ asset_code: string; asset_issuer: string | null }>;
+ memo?: string | null;
+ memoType?: string | null;
horizonUrl: string;
networkPassphrase: string;
}
@@ -39,6 +43,28 @@ export function resolveAsset(assetCode: string, assetIssuer: string | null): Ste
return new StellarSdk.Asset(assetCode, assetIssuer);
}
+function resolveMemo(
+ memo: string | null | undefined,
+ memoType: string | null | undefined
+): StellarSdk.Memo | undefined {
+ if (!memo || !memoType) {
+ return undefined;
+ }
+
+ switch (memoType.toLowerCase()) {
+ case "text":
+ return StellarSdk.Memo.text(memo);
+ case "id":
+ return StellarSdk.Memo.id(memo);
+ case "hash":
+ return StellarSdk.Memo.hash(memo);
+ case "return":
+ return StellarSdk.Memo.return(memo);
+ default:
+ throw new Error(`Unsupported memo type: ${memoType}`);
+ }
+}
+
/**
* Build a payment transaction for submission to the Stellar network
*/
@@ -55,7 +81,7 @@ export async function buildPaymentTransaction(
const asset = resolveAsset(params.assetCode, params.assetIssuer);
// Build the transaction
- const transaction = new StellarSdk.TransactionBuilder(sourceAccount, {
+ const transactionBuilder = new StellarSdk.TransactionBuilder(sourceAccount, {
fee: StellarSdk.BASE_FEE,
networkPassphrase: params.networkPassphrase,
})
@@ -65,9 +91,14 @@ export async function buildPaymentTransaction(
asset: asset,
amount: params.amount,
})
- )
- .setTimeout(300)
- .build();
+ );
+
+ const memo = resolveMemo(params.memo, params.memoType);
+ if (memo) {
+ transactionBuilder.addMemo(memo);
+ }
+
+ const transaction = transactionBuilder.setTimeout(300).build();
return transaction.toXDR();
} catch (error) {
@@ -94,7 +125,7 @@ export async function buildPathPaymentTransaction(
const stellarPath = params.path.map((p) => resolveAsset(p.asset_code, p.asset_issuer));
- const transaction = new StellarSdk.TransactionBuilder(sourceAccount, {
+ const transactionBuilder = new StellarSdk.TransactionBuilder(sourceAccount, {
fee: StellarSdk.BASE_FEE,
networkPassphrase: params.networkPassphrase,
})
@@ -107,9 +138,14 @@ export async function buildPathPaymentTransaction(
destAmount: params.destAmount,
path: stellarPath,
})
- )
- .setTimeout(300)
- .build();
+ );
+
+ const memo = resolveMemo(params.memo, params.memoType);
+ if (memo) {
+ transactionBuilder.addMemo(memo);
+ }
+
+ const transaction = transactionBuilder.setTimeout(300).build();
return transaction.toXDR();
} catch (error) {
diff --git a/frontend/src/lib/usePayment.ts b/frontend/src/lib/usePayment.ts
index 33216468..bc01457a 100644
--- a/frontend/src/lib/usePayment.ts
+++ b/frontend/src/lib/usePayment.ts
@@ -8,6 +8,8 @@ interface PaymentParams {
amount: string;
assetCode: string;
assetIssuer: string | null;
+ memo?: string | null;
+ memoType?: string | null;
}
interface PathPaymentParams {
@@ -19,6 +21,8 @@ interface PathPaymentParams {
sendAssetCode: string;
sendAssetIssuer: string | null;
path: Array<{ asset_code: string; asset_issuer: string | null }>;
+ memo?: string | null;
+ memoType?: string | null;
}
interface UsePaymentReturn {
@@ -69,6 +73,8 @@ export function usePayment(provider: WalletProvider | null): UsePaymentReturn {
amount: params.amount,
assetCode: params.assetCode,
assetIssuer: params.assetIssuer,
+ memo: params.memo,
+ memoType: params.memoType,
horizonUrl: networkUrl,
networkPassphrase,
});
@@ -133,6 +139,8 @@ export function usePayment(provider: WalletProvider | null): UsePaymentReturn {
destAssetCode: params.destAssetCode,
destAssetIssuer: params.destAssetIssuer,
path: params.path,
+ memo: params.memo,
+ memoType: params.memoType,
horizonUrl: networkUrl,
networkPassphrase,
});
From 45fef2308723fbda01dd9d78b96d76acfc690b11 Mon Sep 17 00:00:00 2001
From: Marvell69
Date: Sat, 28 Mar 2026 00:51:54 +0100
Subject: [PATCH 2/3] test: add verification coverage for XLM path payments
---
.../frontend-path-payment-transaction.test.js | 128 ++++++++++++++++++
.../tests/e2e/checkout-path-payment.spec.ts | 108 +++++++++++++++
2 files changed, 236 insertions(+)
create mode 100644 backend/src/verification/frontend-path-payment-transaction.test.js
create mode 100644 frontend/tests/e2e/checkout-path-payment.spec.ts
diff --git a/backend/src/verification/frontend-path-payment-transaction.test.js b/backend/src/verification/frontend-path-payment-transaction.test.js
new file mode 100644
index 00000000..fbc32eee
--- /dev/null
+++ b/backend/src/verification/frontend-path-payment-transaction.test.js
@@ -0,0 +1,128 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const {
+ frontendStellarSdkPath,
+ nativeAsset,
+ textMemo,
+ pathPaymentStrictReceiveOperation,
+ transactionBuilderFactory,
+ loadAccount,
+} = vi.hoisted(() => ({
+ frontendStellarSdkPath:
+ "/Users/marvellous/Desktop/Stellar_Payment_API/frontend/node_modules/stellar-sdk/lib/index.js",
+ nativeAsset: { type: "native" },
+ textMemo: vi.fn(),
+ pathPaymentStrictReceiveOperation: vi.fn(),
+ transactionBuilderFactory: vi.fn(),
+ loadAccount: vi.fn(),
+}));
+
+vi.mock(frontendStellarSdkPath, () => {
+ class MockAsset {
+ constructor(code, issuer) {
+ this.code = code;
+ this.issuer = issuer;
+ this.type = "credit";
+ }
+
+ static native() {
+ return nativeAsset;
+ }
+ }
+
+ return {
+ Asset: MockAsset,
+ BASE_FEE: "100",
+ Memo: {
+ text: textMemo,
+ id: vi.fn(),
+ hash: vi.fn(),
+ return: vi.fn(),
+ },
+ Horizon: {
+ Server: vi.fn(() => ({
+ loadAccount,
+ })),
+ },
+ Operation: {
+ payment: vi.fn(),
+ pathPaymentStrictReceive: pathPaymentStrictReceiveOperation,
+ },
+ TransactionBuilder: transactionBuilderFactory,
+ };
+});
+
+describe("frontend Stellar path payment builder", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ });
+
+ it("builds a strict-receive path payment with memo for an XLM to USDC invoice", async () => {
+ const addOperation = vi.fn();
+ const addMemo = vi.fn();
+ const setTimeout = vi.fn();
+ const build = vi.fn();
+
+ addOperation.mockReturnThis();
+ addMemo.mockReturnThis();
+ setTimeout.mockReturnThis();
+ build.mockReturnValue({
+ toXDR: () => "AAAA-path-payment-xdr",
+ });
+
+ transactionBuilderFactory.mockImplementation(() => ({
+ addOperation,
+ addMemo,
+ setTimeout,
+ build,
+ }));
+
+ loadAccount.mockResolvedValue({
+ accountId: () => "GTESTSOURCEACCOUNT",
+ sequence: "1234567890",
+ });
+
+ textMemo.mockImplementation((value) => ({ type: "text", value }));
+ pathPaymentStrictReceiveOperation.mockImplementation((params) => ({
+ type: "path_payment_strict_receive",
+ ...params,
+ }));
+
+ const { buildPathPaymentTransaction } = await import(
+ "../../../frontend/src/lib/stellar.ts"
+ );
+
+ const xdr = await buildPathPaymentTransaction({
+ sourcePublicKey: "GTESTSOURCEACCOUNT",
+ destinationPublicKey: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
+ sendMax: "60.7262500",
+ sendAssetCode: "XLM",
+ sendAssetIssuer: null,
+ destAmount: "25.0000000",
+ destAssetCode: "USDC",
+ destAssetIssuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
+ path: [],
+ memo: "invoice-123",
+ memoType: "text",
+ horizonUrl: "https://horizon-testnet.stellar.org",
+ networkPassphrase: "Test SDF Network ; September 2015",
+ });
+
+ expect(pathPaymentStrictReceiveOperation).toHaveBeenCalledWith({
+ sendAsset: nativeAsset,
+ sendMax: "60.7262500",
+ destination: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
+ destAsset: expect.objectContaining({
+ type: "credit",
+ code: "USDC",
+ issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
+ }),
+ destAmount: "25.0000000",
+ path: [],
+ });
+ expect(textMemo).toHaveBeenCalledWith("invoice-123");
+ expect(addMemo).toHaveBeenCalledWith({ type: "text", value: "invoice-123" });
+ expect(xdr).toBe("AAAA-path-payment-xdr");
+ });
+});
diff --git a/frontend/tests/e2e/checkout-path-payment.spec.ts b/frontend/tests/e2e/checkout-path-payment.spec.ts
new file mode 100644
index 00000000..3f7a2b9a
--- /dev/null
+++ b/frontend/tests/e2e/checkout-path-payment.spec.ts
@@ -0,0 +1,108 @@
+import { expect, test } from "@playwright/test";
+
+const API_BASE = "http://localhost:4000";
+const PAYMENT_ID = "5cb2bf8f-d84b-4e50-8838-dc0ac7ae0f54";
+const PAY_URL = `/pay/${PAYMENT_ID}`;
+const SOURCE_PUBLIC_KEY =
+ "GBRPYHIL2C7Q7PGLUKSTPIY2KPJ7QMZ4ZWJHQ6GUSIW2LQAHOMK5N7BI";
+const DESTINATION_PUBLIC_KEY =
+ "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";
+const USDC_ISSUER =
+ "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
+
+const PAYMENT = {
+ id: PAYMENT_ID,
+ amount: 25,
+ asset: "USDC",
+ asset_issuer: USDC_ISSUER,
+ recipient: DESTINATION_PUBLIC_KEY,
+ description: "Path payment invoice",
+ memo: "invoice-123",
+ memo_type: "text",
+ status: "pending",
+ tx_id: null,
+ created_at: "2026-03-28T12:00:00.000Z",
+ branding_config: null,
+};
+
+test("shows the approximate XLM cost after Freighter connects for a USDC invoice", async ({
+ page,
+}) => {
+ await page.addInitScript(
+ ({ sourcePublicKey }) => {
+ window.addEventListener("message", (event) => {
+ if (event.source !== window) return;
+
+ const data = event.data;
+ if (data?.source !== "FREIGHTER_EXTERNAL_MSG_REQUEST") return;
+
+ const respond = (payload: Record) => {
+ window.postMessage(
+ {
+ source: "FREIGHTER_EXTERNAL_MSG_RESPONSE",
+ messagedId: data.messageId,
+ ...payload,
+ },
+ window.location.origin,
+ );
+ };
+
+ switch (data.type) {
+ case "REQUEST_ALLOWED_STATUS":
+ respond({ isAllowed: true });
+ break;
+ case "REQUEST_ACCESS":
+ respond({ publicKey: sourcePublicKey });
+ break;
+ default:
+ break;
+ }
+ });
+ },
+ { sourcePublicKey: SOURCE_PUBLIC_KEY },
+ );
+
+ await page.route(`${API_BASE}/api/payment-status/**`, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ payment: PAYMENT }),
+ });
+ });
+
+ await page.route(`${API_BASE}/api/path-payment-quote/**`, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ source_asset: "XLM",
+ source_asset_issuer: null,
+ source_amount: "60.1250000",
+ send_max: "60.7262500",
+ destination_asset: "USDC",
+ destination_asset_issuer: USDC_ISSUER,
+ destination_amount: "25.0000000",
+ path: [],
+ slippage: 0.01,
+ }),
+ });
+ });
+
+ await page.goto(PAY_URL);
+
+ await expect(page.getByRole("button", { name: /Freighter/i })).toBeEnabled();
+ await page.getByRole("button", { name: /Freighter/i }).click();
+
+ await expect(page.getByText("Connected via Freighter")).toBeVisible();
+ await expect(page.getByText("Approximate cost in XLM")).toBeVisible();
+ await expect(page.getByText("60.125 XLM")).toBeVisible();
+ await expect(
+ page.getByText("1% safety buffer included. Max send: 60.7262500 XLM"),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("checkbox", { name: "Pay with 60.1250000 XLM instead" }),
+ ).toBeChecked();
+ await expect(
+ page.getByRole("button", { name: "Pay 60.7262500 XLM" }),
+ ).toBeVisible();
+});
From 79f9e843696b0700d51e6376d811326bcf73d594 Mon Sep 17 00:00:00 2001
From: Marvell69
Date: Sat, 28 Mar 2026 01:01:20 +0100
Subject: [PATCH 3/3] chore: add Stellar testnet path payment verifier
---
README.md | 9 +-
backend/package.json | 1 +
.../scripts/verify-path-payment-testnet.js | 221 ++++++++++++++++++
3 files changed, 230 insertions(+), 1 deletion(-)
create mode 100644 backend/scripts/verify-path-payment-testnet.js
diff --git a/README.md b/README.md
index ef153dc4..d75fe789 100644
--- a/README.md
+++ b/README.md
@@ -91,6 +91,14 @@ npm run build:docs
This writes `backend/public/openapi.json`.
+Verify the XLM -> USDC path-payment flow on Stellar testnet without a wallet:
+```bash
+cd backend
+npm run verify:path-payment:testnet
+```
+
+This script creates disposable testnet accounts, issues a temporary USDC asset, places a DEX offer, discovers the best XLM -> USDC path, submits a live `path_payment_strict_receive`, and prints the transaction hash plus the recipient's received USDC amount.
+
## API Endpoints
- `GET /health`
@@ -157,4 +165,3 @@ The project currently has a comprehensive roadmap of **100+ active issues** cove
We are actively seeking contributors! See the [GitHub Issues](https://github.com/emdevelopa/Stellar_Payment_API/issues) to get started. Each issue is tagged with complexity (`complexity:trivial`, `complexity:medium`, `complexity:high`) and category.
If you are new, look for issues labeled `good first issue`.
-
diff --git a/backend/package.json b/backend/package.json
index a5b95b97..c7a3baeb 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -7,6 +7,7 @@
"dev": "nodemon src/server.js",
"start": "node src/server.js",
"build:docs": "node scripts/generate-swagger.js",
+ "verify:path-payment:testnet": "node scripts/verify-path-payment-testnet.js",
"test": "vitest run",
"test:integration": "node --experimental-vm-modules node_modules/.bin/jest jest.config.js tests/integration",
"purge:webhook-logs": "node scripts/purge-webhook-logs.js",
diff --git a/backend/scripts/verify-path-payment-testnet.js b/backend/scripts/verify-path-payment-testnet.js
new file mode 100644
index 00000000..4f617a3c
--- /dev/null
+++ b/backend/scripts/verify-path-payment-testnet.js
@@ -0,0 +1,221 @@
+import "dotenv/config";
+import * as StellarSdk from "stellar-sdk";
+
+const NETWORK = (process.env.STELLAR_NETWORK || "testnet").toLowerCase();
+const HORIZON_URL =
+ process.env.STELLAR_HORIZON_URL ||
+ (NETWORK === "public"
+ ? "https://horizon.stellar.org"
+ : "https://horizon-testnet.stellar.org");
+const NETWORK_PASSPHRASE =
+ NETWORK === "public"
+ ? StellarSdk.Networks.PUBLIC
+ : StellarSdk.Networks.TESTNET;
+const FRIEND_BOT_URL =
+ process.env.STELLAR_FRIENDBOT_URL || "https://friendbot.stellar.org";
+
+if (NETWORK !== "testnet") {
+ console.error("This verifier only supports Stellar testnet.");
+ process.exit(1);
+}
+
+const server = new StellarSdk.Horizon.Server(HORIZON_URL);
+
+function assertSuccess(result, label) {
+ if (!result.successful) {
+ throw new Error(`${label} failed: transaction was not successful`);
+ }
+}
+
+async function fundAccount(publicKey) {
+ const response = await fetch(`${FRIEND_BOT_URL}?addr=${publicKey}`);
+ if (!response.ok) {
+ const body = await response.text();
+ throw new Error(`Friendbot funding failed for ${publicKey}: ${body}`);
+ }
+ return response.json();
+}
+
+async function submitTransaction(sourceKeypair, operationBuilder, label) {
+ const sourceAccount = await server.loadAccount(sourceKeypair.publicKey());
+ const txBuilder = new StellarSdk.TransactionBuilder(sourceAccount, {
+ fee: StellarSdk.BASE_FEE,
+ networkPassphrase: NETWORK_PASSPHRASE,
+ });
+
+ operationBuilder(txBuilder);
+
+ const transaction = txBuilder.setTimeout(60).build();
+ transaction.sign(sourceKeypair);
+
+ const result = await server.submitTransaction(transaction);
+ assertSuccess(result, label);
+ return result;
+}
+
+async function getAssetBalance(accountId, asset) {
+ const account = await server.loadAccount(accountId);
+ const balanceLine = account.balances.find((balance) => {
+ if (asset.isNative()) {
+ return balance.asset_type === "native";
+ }
+
+ return (
+ balance.asset_code === asset.getCode() &&
+ balance.asset_issuer === asset.getIssuer()
+ );
+ });
+
+ return balanceLine ? Number(balanceLine.balance) : 0;
+}
+
+function formatAmount(value) {
+ return Number(value).toFixed(7);
+}
+
+async function main() {
+ console.log(`Using Horizon: ${HORIZON_URL}`);
+
+ const issuer = StellarSdk.Keypair.random();
+ const marketMaker = StellarSdk.Keypair.random();
+ const sender = StellarSdk.Keypair.random();
+ const recipient = StellarSdk.Keypair.random();
+
+ console.log("Funding issuer, market maker, sender, and recipient...");
+ await Promise.all(
+ [issuer, marketMaker, sender, recipient].map((keypair) =>
+ fundAccount(keypair.publicKey()),
+ ),
+ );
+
+ const usdcAsset = new StellarSdk.Asset("USDC", issuer.publicKey());
+ const invoiceAmount = "25.0000000";
+ const offerAmount = "500.0000000";
+ const offerPrice = "2.0000000";
+
+ console.log("Creating trustlines for USDC...");
+ await submitTransaction(
+ marketMaker,
+ (txBuilder) => {
+ txBuilder.addOperation(
+ StellarSdk.Operation.changeTrust({
+ asset: usdcAsset,
+ }),
+ );
+ },
+ "market-maker trustline",
+ );
+
+ await submitTransaction(
+ recipient,
+ (txBuilder) => {
+ txBuilder.addOperation(
+ StellarSdk.Operation.changeTrust({
+ asset: usdcAsset,
+ }),
+ );
+ },
+ "recipient trustline",
+ );
+
+ console.log("Issuing USDC to the market maker...");
+ await submitTransaction(
+ issuer,
+ (txBuilder) => {
+ txBuilder.addOperation(
+ StellarSdk.Operation.payment({
+ destination: marketMaker.publicKey(),
+ asset: usdcAsset,
+ amount: offerAmount,
+ }),
+ );
+ },
+ "issue USDC",
+ );
+
+ console.log("Placing a DEX sell offer for USDC/XLM...");
+ await submitTransaction(
+ marketMaker,
+ (txBuilder) => {
+ txBuilder.addOperation(
+ StellarSdk.Operation.manageSellOffer({
+ selling: usdcAsset,
+ buying: StellarSdk.Asset.native(),
+ amount: offerAmount,
+ price: offerPrice,
+ offerId: "0",
+ }),
+ );
+ },
+ "create sell offer",
+ );
+
+ console.log("Discovering strict-receive path from XLM to USDC...");
+ const paths = await server
+ .strictReceivePaths([StellarSdk.Asset.native()], usdcAsset, invoiceAmount)
+ .call();
+
+ if (!paths.records?.length) {
+ throw new Error("No strict-receive path found for XLM -> USDC");
+ }
+
+ const bestPath = paths.records[0];
+ const sourceAmount = Number(bestPath.source_amount);
+ const sendMax = formatAmount(sourceAmount * 1.01);
+ const path = bestPath.path.map((asset) =>
+ asset.asset_type === "native"
+ ? StellarSdk.Asset.native()
+ : new StellarSdk.Asset(asset.asset_code, asset.asset_issuer),
+ );
+
+ const recipientBalanceBefore = await getAssetBalance(
+ recipient.publicKey(),
+ usdcAsset,
+ );
+
+ console.log(
+ `Submitting path payment. Source amount estimate: ${bestPath.source_amount} XLM, send max: ${sendMax} XLM`,
+ );
+
+ const paymentResult = await submitTransaction(
+ sender,
+ (txBuilder) => {
+ txBuilder
+ .addOperation(
+ StellarSdk.Operation.pathPaymentStrictReceive({
+ sendAsset: StellarSdk.Asset.native(),
+ sendMax,
+ destination: recipient.publicKey(),
+ destAsset: usdcAsset,
+ destAmount: invoiceAmount,
+ path,
+ }),
+ )
+ .addMemo(StellarSdk.Memo.text("path-payment-check"));
+ },
+ "path payment",
+ );
+
+ const recipientBalanceAfter = await getAssetBalance(
+ recipient.publicKey(),
+ usdcAsset,
+ );
+ const receivedDelta = formatAmount(recipientBalanceAfter - recipientBalanceBefore);
+
+ if (receivedDelta !== invoiceAmount) {
+ throw new Error(
+ `Recipient received ${receivedDelta} USDC instead of expected ${invoiceAmount} USDC`,
+ );
+ }
+
+ console.log("Path payment verification succeeded.");
+ console.log(`Transaction hash: ${paymentResult.hash}`);
+ console.log(`Recipient received: ${receivedDelta} USDC`);
+ console.log(`Recipient account: ${recipient.publicKey()}`);
+}
+
+main().catch((error) => {
+ console.error("Path payment verification failed.");
+ console.error(error instanceof Error ? error.message : error);
+ process.exit(1);
+});