From 491c734851590ba28c5c8b77de282024fa1a0e06 Mon Sep 17 00:00:00 2001 From: Tijesunimi004 Date: Thu, 23 Jul 2026 15:39:10 +0100 Subject: [PATCH] feat: add pre-flight payment safety checks with SEP-29 memo detection Add preflightPayment() to stellarService.js, validating destination existence, USDC trustline, source balance/reserve, and SEP-29 memo-required status before an unsigned XDR is built. Wire it into initializePayment (400 with structured reasons, no Transaction row created on failure) and expose it standalone via POST /api/stellar/payment/preflight so the frontend can surface blocking issues before prompting the wallet to sign. Centralize the platform's DNB-- memo construction into a single helper shared by both call sites. --- src/controllers/stellar/paymentController.js | 202 +++++++++++--- src/routes/stellar/paymentRoutes.js | 2 + src/services/stellar/stellarService.js | 151 +++++++++- test/preflightPayment.test.js | 272 +++++++++++++++++++ 4 files changed, 577 insertions(+), 50 deletions(-) create mode 100644 test/preflightPayment.test.js diff --git a/src/controllers/stellar/paymentController.js b/src/controllers/stellar/paymentController.js index 28272a4..d06a589 100644 --- a/src/controllers/stellar/paymentController.js +++ b/src/controllers/stellar/paymentController.js @@ -8,6 +8,8 @@ import { buildPaymentTransaction, buildPathPaymentTransaction, buildSep7Uri, + calculateFeeSplit, + preflightPayment, submitTransaction, verifyTransaction, verifyPaymentOperations, @@ -29,6 +31,66 @@ import { paymentsFailed, } from "../../config/metrics.js"; +/** + * Resolve the item, its creator, and the settlement destination wallet for a + * purchase. Shared by initializePayment and the pre-flight endpoint so both + * look up the same destination the same way. + */ +const resolvePaymentDestination = async ({ itemType, itemId, session }) => { + const Model = itemType === "book" ? Book : Course; + const populateField = itemType === "book" ? "author" : "createdBy"; + + const query = Model.findById(itemId).populate(populateField, "stellarWallet name"); + const item = session ? await query.session(session) : await query; + + if (!item) { + return { error: { status: 404, message: `${itemType} not found` } }; + } + + const creator = itemType === "book" ? item.author : item.createdBy; + const platformCollectEnabled = process.env.PLATFORM_COLLECT_ENABLED === "true"; + let destinationPublicKey; + let settlementMode = "direct"; + + if (!creator?.stellarWallet?.publicKey) { + if (!platformCollectEnabled) { + return { + error: { + status: 400, + message: "Creator has not connected their Stellar wallet yet", + }, + }; + } + + const platformWalletKey = + process.env.PLATFORM_WALLET_PUBLIC_KEY || PLATFORM_WALLET_PUBLIC_KEY; + if (!platformWalletKey) { + return { + error: { + status: 500, + message: "Platform wallet is not configured for platform-collect mode", + }, + }; + } + + destinationPublicKey = platformWalletKey; + settlementMode = "platform_collect"; + } else { + destinationPublicKey = creator.stellarWallet.publicKey; + } + + return { item, creator, destinationPublicKey, settlementMode }; +}; + +/** + * Platform memo convention: purchases are tagged DNB--, always as a text memo. This is always non-empty, so + * it already satisfies SEP-29 "some memo present" destinations; it does not + * substitute for a destination-specific memo (e.g. an exchange deposit id). + */ +const buildPurchaseMemo = (itemType, itemId) => + `DNB-${itemType.toUpperCase()}-${itemId.toString().slice(-8)}`; + /** * Get a quote for paying with a non-USDC asset via path payment * POST /api/stellar/payment/quote @@ -140,6 +202,76 @@ export const getQuote = async (req, res) => { } }; +/** + * Run pre-flight payment safety checks (destination existence, USDC + * trustline, source balance/reserve, SEP-29 memo-required) before the + * frontend prompts the wallet to sign anything. + * POST /api/stellar/payment/preflight + */ +export const getPaymentPreflight = async (req, res) => { + try { + const buyerId = req.user._id; + const { itemType, itemId } = req.body; + + if (!["book", "course"].includes(itemType)) { + return res.status(400).json({ + success: false, + message: "Invalid item type. Must be 'book' or 'course'", + }); + } + + const buyer = await User.findById(buyerId); + if (!buyer?.stellarWallet?.publicKey) { + return res.status(400).json({ + success: false, + message: "Please connect your Stellar wallet first", + }); + } + + const resolved = await resolvePaymentDestination({ itemType, itemId }); + if (resolved.error) { + return res.status(resolved.error.status).json({ + success: false, + message: resolved.error.message, + }); + } + + const { item, destinationPublicKey, settlementMode } = resolved; + + if (!item.price || item.price === 0) { + return res.status(400).json({ + success: false, + message: "This item is free, no payment required", + }); + } + + const memo = buildPurchaseMemo(itemType, itemId); + const feeSplitPreview = + settlementMode === "direct" ? calculateFeeSplit(item.price) : null; + + const preflight = await preflightPayment({ + sourcePublicKey: buyer.stellarWallet.publicKey, + destinationPublicKey, + amount: item.price.toString(), + memo, + operationCount: feeSplitPreview ? 2 : 1, + }); + + res.status(200).json({ + success: true, + preflight, + }); + } catch (error) { + logger.error("Payment preflight error:", error); + res.status(500).json({ + success: false, + message: "Failed to run payment pre-flight checks", + error: + process.env.NODE_ENV === "development" ? error.message : undefined, + }); + } +}; + /** * Initialize a payment - creates pending transaction and returns XDR to sign * POST /api/stellar/payment/initialize @@ -180,53 +312,17 @@ export const initializePayment = async (req, res) => { }); } - // Get item details - const Model = itemType === "book" ? Book : Course; - const populateField = itemType === "book" ? "author" : "createdBy"; - - const item = await Model.findById(itemId) - .populate(populateField, "stellarWallet name") - .session(session); - - if (!item) { + // Get item details and resolve the settlement destination + const resolved = await resolvePaymentDestination({ itemType, itemId, session }); + if (resolved.error) { await session.abortTransaction(); - return res.status(404).json({ + return res.status(resolved.error.status).json({ success: false, - message: `${itemType} not found`, + message: resolved.error.message, }); } - const creator = itemType === "book" ? item.author : item.createdBy; - const platformCollectEnabled = - process.env.PLATFORM_COLLECT_ENABLED === "true"; - let destinationPublicKey; - let settlementMode = "direct"; - - // Check creator has wallet or platform-collect mode is enabled - if (!creator?.stellarWallet?.publicKey) { - if (!platformCollectEnabled) { - await session.abortTransaction(); - return res.status(400).json({ - success: false, - message: "Creator has not connected their Stellar wallet yet", - }); - } - - const platformWalletKey = - process.env.PLATFORM_WALLET_PUBLIC_KEY || PLATFORM_WALLET_PUBLIC_KEY; - if (!platformWalletKey) { - await session.abortTransaction(); - return res.status(500).json({ - success: false, - message: "Platform wallet is not configured for platform-collect mode", - }); - } - - destinationPublicKey = platformWalletKey; - settlementMode = "platform_collect"; - } else { - destinationPublicKey = creator.stellarWallet.publicKey; - } + const { item, creator, destinationPublicKey, settlementMode } = resolved; // Check if item is free if (!item.price || item.price === 0) { @@ -271,7 +367,7 @@ export const initializePayment = async (req, res) => { } // Generate unique memo for this transaction - const memo = `DNB-${itemType.toUpperCase()}-${itemId.toString().slice(-8)}`; + const memo = buildPurchaseMemo(itemType, itemId); // Build the payment transaction (single op full amount for platform collect, split for direct if fee configured) const isPathPayment = sendAssetInput && sendMax; @@ -302,6 +398,26 @@ export const initializePayment = async (req, res) => { applyPlatformFee: settlementMode === "direct", }); } else { + const feeSplitPreview = + settlementMode === "direct" ? calculateFeeSplit(item.price) : null; + + const preflight = await preflightPayment({ + sourcePublicKey: buyer.stellarWallet.publicKey, + destinationPublicKey, + amount: item.price.toString(), + memo, + operationCount: feeSplitPreview ? 2 : 1, + }); + + if (!preflight.ok) { + await session.abortTransaction(); + return res.status(400).json({ + success: false, + message: "Payment failed pre-flight safety checks", + reasons: preflight.reasons, + }); + } + paymentTx = await buildPaymentTransaction({ sourcePublicKey: buyer.stellarWallet.publicKey, destinationPublicKey, diff --git a/src/routes/stellar/paymentRoutes.js b/src/routes/stellar/paymentRoutes.js index 114e68e..34aa108 100644 --- a/src/routes/stellar/paymentRoutes.js +++ b/src/routes/stellar/paymentRoutes.js @@ -5,6 +5,7 @@ import { initializePayment, submitPayment, getQuote, + getPaymentPreflight, getTransactionHistory, getTransaction, cancelTransaction, @@ -17,6 +18,7 @@ router.use(protect); // Payment flow router.post("/quote", getQuote); +router.post("/preflight", getPaymentPreflight); router.post("/initialize", initializePayment); router.post("/submit", submitPayment); diff --git a/src/services/stellar/stellarService.js b/src/services/stellar/stellarService.js index e697768..98d4346 100644 --- a/src/services/stellar/stellarService.js +++ b/src/services/stellar/stellarService.js @@ -248,21 +248,32 @@ export const isValidPublicKey = (publicKey) => { } }; +const parseAccountSummary = (account) => { + const usdcBalance = account.balances?.find( + (b) => b.asset_code === "USDC" && b.asset_issuer === USDC_ISSUER + ); + const xlmBalance = account.balances?.find((b) => b.asset_type === "native"); + + return { + xlmBalance: xlmBalance?.balance || "0", + usdcBalance: usdcBalance?.balance || "0", + hasTrustline: !!usdcBalance, + subentryCount: account.subentry_count ?? 0, + }; +}; + export const getAccountBalance = async (publicKey) => { try { const account = await timedHorizonCall("loadAccount", () => server.loadAccount(publicKey) ); - const usdcBalance = account.balances.find( - (b) => b.asset_code === "USDC" && b.asset_issuer === USDC_ISSUER - ); + const summary = parseAccountSummary(account); return { exists: true, - xlmBalance: - account.balances.find((b) => b.asset_type === "native")?.balance || "0", - usdcBalance: usdcBalance?.balance || "0", - hasTrustline: !!usdcBalance, + xlmBalance: summary.xlmBalance, + usdcBalance: summary.usdcBalance, + hasTrustline: summary.hasTrustline, }; } catch (error) { if (error.response?.status === 404) { @@ -278,6 +289,132 @@ export const getAccountBalance = async (publicKey) => { } }; +// SEP-29: an account opts into requiring a memo on incoming payments by +// setting a manageData entry with key "config.memo_required" (value is +// conventionally "1", base64-encoded by Horizon like all data_attr values). +export const MEMO_REQUIRED_DATA_KEY = "config.memo_required"; + +export const isMemoRequired = (account) => { + const raw = account?.data_attr?.[MEMO_REQUIRED_DATA_KEY]; + if (!raw) return false; + try { + const decoded = Buffer.from(raw, "base64").toString("utf8").trim(); + return decoded === "1" || decoded.toLowerCase() === "true"; + } catch { + return false; + } +}; + +export const PREFLIGHT_REASON_CODES = Object.freeze({ + SOURCE_ACCOUNT_MISSING: "source_account_missing", + DESTINATION_ACCOUNT_MISSING: "destination_account_missing", + DESTINATION_NO_TRUSTLINE: "destination_no_trustline", + SOURCE_INSUFFICIENT_BALANCE: "source_insufficient_balance", + SOURCE_INSUFFICIENT_RESERVE: "source_insufficient_reserve", + DESTINATION_MEMO_REQUIRED: "destination_memo_required", +}); + +// Stellar protocol base reserve is 0.5 XLM per ledger entry (2 base entries +// per account, plus one more per subentry: trustlines, offers, signers, data). +const BASE_RESERVE_STROOPS = 5000000n; + +const loadAccountOrNull = async (publicKey) => { + try { + return await timedHorizonCall("loadAccount", () => + server.loadAccount(publicKey) + ); + } catch (error) { + if (error.response?.status === 404) { + return null; + } + throw error; + } +}; + +/** + * Validate a prospective USDC payment before an unsigned XDR is built, so the + * wallet is never asked to sign something that will bounce on submission. + * Only account-not-found is treated as a structured reason; other Horizon + * errors (network, rate limit) propagate to the caller. + */ +export const preflightPayment = async ({ + sourcePublicKey, + destinationPublicKey, + amount, + memo, + operationCount = 1, +}) => { + const reasons = []; + const warnings = []; + + const [sourceAccount, destinationAccount] = await Promise.all([ + loadAccountOrNull(sourcePublicKey), + loadAccountOrNull(destinationPublicKey), + ]); + + if (!sourceAccount) { + reasons.push({ + code: PREFLIGHT_REASON_CODES.SOURCE_ACCOUNT_MISSING, + message: "Your Stellar account does not exist or is unfunded on the network.", + }); + } else { + const summary = parseAccountSummary(sourceAccount); + const requiredStroops = toStroops(amount); + const availableStroops = toStroops(summary.usdcBalance); + + if (availableStroops < requiredStroops) { + reasons.push({ + code: PREFLIGHT_REASON_CODES.SOURCE_INSUFFICIENT_BALANCE, + message: "Your wallet does not hold enough USDC to complete this payment.", + }); + } + + const minReserveStroops = + (2n + BigInt(summary.subentryCount)) * BASE_RESERVE_STROOPS; + const feeStroops = BigInt(StellarSdk.BASE_FEE) * BigInt(operationCount); + const xlmAvailableStroops = toStroops(summary.xlmBalance); + + if (xlmAvailableStroops < minReserveStroops + feeStroops) { + reasons.push({ + code: PREFLIGHT_REASON_CODES.SOURCE_INSUFFICIENT_RESERVE, + message: + "Your wallet does not hold enough XLM to cover the minimum reserve and network fee.", + }); + } + } + + if (!destinationAccount) { + reasons.push({ + code: PREFLIGHT_REASON_CODES.DESTINATION_ACCOUNT_MISSING, + message: "Recipient account does not exist or is unfunded on the network.", + }); + } else { + const summary = parseAccountSummary(destinationAccount); + + if (!summary.hasTrustline) { + reasons.push({ + code: PREFLIGHT_REASON_CODES.DESTINATION_NO_TRUSTLINE, + message: + "Recipient needs to add a USDC trustline to their wallet before they can receive this payment.", + }); + } + + if (isMemoRequired(destinationAccount) && !memo) { + reasons.push({ + code: PREFLIGHT_REASON_CODES.DESTINATION_MEMO_REQUIRED, + message: + "Recipient requires a memo on incoming payments (SEP-29), commonly the case for exchange or custodial wallets.", + }); + } + } + + return { + ok: reasons.length === 0, + reasons, + warnings, + }; +}; + export const buildPaymentTransaction = async ({ sourcePublicKey, destinationPublicKey, diff --git a/test/preflightPayment.test.js b/test/preflightPayment.test.js new file mode 100644 index 0000000..b21f94d --- /dev/null +++ b/test/preflightPayment.test.js @@ -0,0 +1,272 @@ +import { jest } from "@jest/globals"; +import { + preflightPayment, + isMemoRequired, + MEMO_REQUIRED_DATA_KEY, + PREFLIGHT_REASON_CODES, + USDC_ISSUER, + server, +} from "../src/services/stellar/stellarService.js"; + +const SOURCE = "GASOURCE000000000000000000000000000000000000000000000000"; +const DESTINATION = "GADEST0000000000000000000000000000000000000000000000000"; + +const memoRequiredDataAttr = () => ({ + [MEMO_REQUIRED_DATA_KEY]: Buffer.from("1").toString("base64"), +}); + +const fundedAccount = ({ + xlm = "10", + usdc = "100", + hasTrustline = true, + subentryCount = 1, + dataAttr, +} = {}) => ({ + balances: [ + { asset_type: "native", balance: xlm }, + ...(hasTrustline + ? [{ asset_code: "USDC", asset_issuer: USDC_ISSUER, balance: usdc }] + : []), + ], + subentry_count: subentryCount, + ...(dataAttr && { data_attr: dataAttr }), +}); + +const notFoundError = () => { + const error = new Error("Not Found"); + error.response = { status: 404 }; + return error; +}; + +const mockLoadAccount = (impl) => { + jest.spyOn(server, "loadAccount").mockImplementation(impl); +}; + +describe("preflightPayment", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("passes with no reasons when source and destination are both healthy", async () => { + mockLoadAccount(async (key) => + key === SOURCE ? fundedAccount() : fundedAccount() + ); + + const result = await preflightPayment({ + sourcePublicKey: SOURCE, + destinationPublicKey: DESTINATION, + amount: "50", + memo: "DNB-BOOK-12345678", + }); + + expect(result.ok).toBe(true); + expect(result.reasons).toEqual([]); + }); + + it("flags destination_account_missing when destination is unfunded (404)", async () => { + mockLoadAccount(async (key) => { + if (key === DESTINATION) throw notFoundError(); + return fundedAccount(); + }); + + const result = await preflightPayment({ + sourcePublicKey: SOURCE, + destinationPublicKey: DESTINATION, + amount: "50", + memo: "DNB-BOOK-12345678", + }); + + expect(result.ok).toBe(false); + expect(result.reasons.map((r) => r.code)).toContain( + PREFLIGHT_REASON_CODES.DESTINATION_ACCOUNT_MISSING + ); + }); + + it("flags source_account_missing when source is unfunded (404)", async () => { + mockLoadAccount(async (key) => { + if (key === SOURCE) throw notFoundError(); + return fundedAccount(); + }); + + const result = await preflightPayment({ + sourcePublicKey: SOURCE, + destinationPublicKey: DESTINATION, + amount: "50", + memo: "DNB-BOOK-12345678", + }); + + expect(result.ok).toBe(false); + expect(result.reasons.map((r) => r.code)).toContain( + PREFLIGHT_REASON_CODES.SOURCE_ACCOUNT_MISSING + ); + }); + + it("flags destination_no_trustline when the destination has no USDC trustline", async () => { + mockLoadAccount(async (key) => + key === DESTINATION + ? fundedAccount({ hasTrustline: false }) + : fundedAccount() + ); + + const result = await preflightPayment({ + sourcePublicKey: SOURCE, + destinationPublicKey: DESTINATION, + amount: "50", + memo: "DNB-BOOK-12345678", + }); + + expect(result.ok).toBe(false); + expect(result.reasons.map((r) => r.code)).toContain( + PREFLIGHT_REASON_CODES.DESTINATION_NO_TRUSTLINE + ); + }); + + it("flags source_insufficient_balance when the source USDC balance is too low", async () => { + mockLoadAccount(async (key) => + key === SOURCE ? fundedAccount({ usdc: "10" }) : fundedAccount() + ); + + const result = await preflightPayment({ + sourcePublicKey: SOURCE, + destinationPublicKey: DESTINATION, + amount: "50", + memo: "DNB-BOOK-12345678", + }); + + expect(result.ok).toBe(false); + expect(result.reasons.map((r) => r.code)).toContain( + PREFLIGHT_REASON_CODES.SOURCE_INSUFFICIENT_BALANCE + ); + }); + + it("flags source_insufficient_reserve when XLM balance can't cover the minimum reserve", async () => { + mockLoadAccount(async (key) => + key === SOURCE + ? fundedAccount({ xlm: "0.5", subentryCount: 1 }) + : fundedAccount() + ); + + const result = await preflightPayment({ + sourcePublicKey: SOURCE, + destinationPublicKey: DESTINATION, + amount: "50", + memo: "DNB-BOOK-12345678", + }); + + expect(result.ok).toBe(false); + expect(result.reasons.map((r) => r.code)).toContain( + PREFLIGHT_REASON_CODES.SOURCE_INSUFFICIENT_RESERVE + ); + }); + + it("accounts for extra operations (fee split) when computing the reserve/fee floor", async () => { + // (2 + 1 subentry) * 0.5 XLM = 1.5 XLM reserve, plus 2 ops * BASE_FEE. + // 1.500011 XLM covers 1 op but not 2. + mockLoadAccount(async (key) => + key === SOURCE + ? fundedAccount({ xlm: "1.500011", subentryCount: 1 }) + : fundedAccount() + ); + + const singleOp = await preflightPayment({ + sourcePublicKey: SOURCE, + destinationPublicKey: DESTINATION, + amount: "50", + memo: "DNB-BOOK-12345678", + operationCount: 1, + }); + expect(singleOp.ok).toBe(true); + + const twoOps = await preflightPayment({ + sourcePublicKey: SOURCE, + destinationPublicKey: DESTINATION, + amount: "50", + memo: "DNB-BOOK-12345678", + operationCount: 2, + }); + expect(twoOps.ok).toBe(false); + expect(twoOps.reasons.map((r) => r.code)).toContain( + PREFLIGHT_REASON_CODES.SOURCE_INSUFFICIENT_RESERVE + ); + }); + + it("SEP-29: flags destination_memo_required when the destination requires a memo and none is given", async () => { + mockLoadAccount(async (key) => + key === DESTINATION + ? fundedAccount({ dataAttr: memoRequiredDataAttr() }) + : fundedAccount() + ); + + const result = await preflightPayment({ + sourcePublicKey: SOURCE, + destinationPublicKey: DESTINATION, + amount: "50", + memo: "", + }); + + expect(result.ok).toBe(false); + expect(result.reasons.map((r) => r.code)).toContain( + PREFLIGHT_REASON_CODES.DESTINATION_MEMO_REQUIRED + ); + }); + + it("SEP-29: passes when the destination requires a memo and a compliant memo is supplied", async () => { + mockLoadAccount(async (key) => + key === DESTINATION + ? fundedAccount({ dataAttr: memoRequiredDataAttr() }) + : fundedAccount() + ); + + const result = await preflightPayment({ + sourcePublicKey: SOURCE, + destinationPublicKey: DESTINATION, + amount: "50", + memo: "DNB-BOOK-12345678", + }); + + expect(result.ok).toBe(true); + expect(result.reasons).toEqual([]); + }); + + it("collects multiple reasons at once rather than short-circuiting", async () => { + mockLoadAccount(async (key) => { + if (key === DESTINATION) throw notFoundError(); + return fundedAccount({ usdc: "0", xlm: "0.1" }); + }); + + const result = await preflightPayment({ + sourcePublicKey: SOURCE, + destinationPublicKey: DESTINATION, + amount: "50", + memo: "DNB-BOOK-12345678", + }); + + expect(result.ok).toBe(false); + const codes = result.reasons.map((r) => r.code); + expect(codes).toContain(PREFLIGHT_REASON_CODES.DESTINATION_ACCOUNT_MISSING); + expect(codes).toContain(PREFLIGHT_REASON_CODES.SOURCE_INSUFFICIENT_BALANCE); + expect(codes).toContain(PREFLIGHT_REASON_CODES.SOURCE_INSUFFICIENT_RESERVE); + }); +}); + +describe("isMemoRequired", () => { + it("returns false when there is no config.memo_required data entry", () => { + expect(isMemoRequired(fundedAccount())).toBe(false); + }); + + it("returns true when config.memo_required is base64-encoded '1'", () => { + expect( + isMemoRequired(fundedAccount({ dataAttr: memoRequiredDataAttr() })) + ).toBe(true); + }); + + it("returns false for a falsy encoded value", () => { + const dataAttr = { [MEMO_REQUIRED_DATA_KEY]: Buffer.from("0").toString("base64") }; + expect(isMemoRequired(fundedAccount({ dataAttr }))).toBe(false); + }); + + it("returns false when given no account", () => { + expect(isMemoRequired(null)).toBe(false); + expect(isMemoRequired(undefined)).toBe(false); + }); +});