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); +});