diff --git a/src/config/assets.js b/src/config/assets.js new file mode 100644 index 00000000..7be03047 --- /dev/null +++ b/src/config/assets.js @@ -0,0 +1,85 @@ +// config/assets.js +// +// Network-aware asset registry. USDC stays the default so existing +// USDC-only flows (and existing Transaction rows with no currency set) +// keep working unchanged. Issuer addresses below are Circle's official +// EURC issuers (verified against developers.circle.com); XLM is native +// and has no issuer. + +const REGISTRY = { + testnet: { + USDC: { + code: "USDC", + issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + decimals: 7, + displayName: "USD Coin", + isDefault: true, + }, + EURC: { + code: "EURC", + issuer: "GB3Q6QDZYTHWT7E5PVS3W7FUT5GVAFC5KSZFFLPU25GO7VTC3NM2ZTVO", + decimals: 7, + displayName: "Euro Coin", + isDefault: false, + }, + XLM: { + code: "XLM", + issuer: null, + decimals: 7, + displayName: "Stellar Lumens", + isDefault: false, + }, + }, + mainnet: { + USDC: { + code: "USDC", + issuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + decimals: 7, + displayName: "USD Coin", + isDefault: true, + }, + EURC: { + code: "EURC", + issuer: "GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2", + decimals: 7, + displayName: "Euro Coin", + isDefault: false, + }, + XLM: { + code: "XLM", + issuer: null, + decimals: 7, + displayName: "Stellar Lumens", + isDefault: false, + }, + }, +}; + +const NETWORK = process.env.STELLAR_NETWORK || "testnet"; + +export const getRegistry = (network = NETWORK) => { + const registry = REGISTRY[network]; + if (!registry) { + throw new Error(`Unknown Stellar network: ${network}`); + } + return registry; +}; + +export const getAssetConfig = (code, network = NETWORK) => { + const registry = getRegistry(network); + return registry[code] || null; +}; + +export const getSupportedCodes = (network = NETWORK) => + Object.keys(getRegistry(network)); + +export const getDefaultAssetCode = (network = NETWORK) => { + const registry = getRegistry(network); + const entry = Object.values(registry).find((a) => a.isDefault); + return entry ? entry.code : "USDC"; +}; + +export const isAssetSupported = (code, network = NETWORK) => + !!getAssetConfig(code, network); + +export { REGISTRY }; \ No newline at end of file diff --git a/src/config/assets.test.js b/src/config/assets.test.js new file mode 100644 index 00000000..3e2f4fe3 --- /dev/null +++ b/src/config/assets.test.js @@ -0,0 +1,41 @@ +import { getRegistry, getAssetConfig, getSupportedCodes, getDefaultAssetCode, isAssetSupported } from "./assets.js"; + +describe("asset registry resolution", () => { + it("resolves USDC as the default asset on testnet", () => { + expect(getDefaultAssetCode("testnet")).toBe("USDC"); + const usdc = getAssetConfig("USDC", "testnet"); + expect(usdc.isDefault).toBe(true); + expect(usdc.issuer).toBe("GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"); + }); + + it("resolves USDC as the default asset on mainnet, with a different issuer than testnet", () => { + expect(getDefaultAssetCode("mainnet")).toBe("USDC"); + const usdc = getAssetConfig("USDC", "mainnet"); + expect(usdc.issuer).toBe("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"); + expect(usdc.issuer).not.toBe(getAssetConfig("USDC", "testnet").issuer); + }); + + it("resolves EURC with the correct issuer per network", () => { + expect(getAssetConfig("EURC", "testnet").issuer).toBe("GB3Q6QDZYTHWT7E5PVS3W7FUT5GVAFC5KSZFFLPU25GO7VTC3NM2ZTVO"); + expect(getAssetConfig("EURC", "mainnet").issuer).toBe("GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2"); + }); + + it("resolves XLM as native (no issuer) on both networks", () => { + expect(getAssetConfig("XLM", "testnet").issuer).toBeNull(); + expect(getAssetConfig("XLM", "mainnet").issuer).toBeNull(); + }); + + it("lists all supported codes per network", () => { + expect(getSupportedCodes("testnet").sort()).toEqual(["EURC", "USDC", "XLM"]); + expect(getSupportedCodes("mainnet").sort()).toEqual(["EURC", "USDC", "XLM"]); + }); + + it("reports unsupported codes as unsupported", () => { + expect(isAssetSupported("DOGE", "testnet")).toBe(false); + expect(getAssetConfig("DOGE", "testnet")).toBeNull(); + }); + + it("throws a clear error for an unknown network", () => { + expect(() => getRegistry("devnet")).toThrow("Unknown Stellar network: devnet"); + }); +}); \ No newline at end of file diff --git a/src/controllers/stellar/paymentController.js b/src/controllers/stellar/paymentController.js index d06a589b..5cc0bfc9 100644 --- a/src/controllers/stellar/paymentController.js +++ b/src/controllers/stellar/paymentController.js @@ -20,6 +20,7 @@ import { USDC, PLATFORM_WALLET_PUBLIC_KEY, } from "../../services/stellar/stellarService.js"; +import { getAssetConfig, isAssetSupported, getSupportedCodes } from "../../config/assets.js"; import * as StellarSdk from "@stellar/stellar-sdk"; import { recordSaleEarnings } from "../../services/payoutService.js"; import { enqueue } from "../../jobs/queue.js"; @@ -39,19 +40,15 @@ import { 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 { @@ -62,8 +59,7 @@ const resolvePaymentDestination = async ({ itemType, itemId, session }) => { }; } - const platformWalletKey = - process.env.PLATFORM_WALLET_PUBLIC_KEY || PLATFORM_WALLET_PUBLIC_KEY; + const platformWalletKey = process.env.PLATFORM_WALLET_PUBLIC_KEY || PLATFORM_WALLET_PUBLIC_KEY; if (!platformWalletKey) { return { error: { @@ -82,6 +78,12 @@ const resolvePaymentDestination = async ({ itemType, itemId, session }) => { return { item, creator, destinationPublicKey, settlementMode }; }; +/** + * Resolve the asset code an item is priced in, defaulting to USDC for + * existing items with no currency set. + */ +const resolveItemCurrency = (item) => item.currency || "USDC"; + /** * Platform memo convention: purchases are tagged DNB--, always as a text memo. This is always non-empty, so @@ -94,6 +96,11 @@ const buildPurchaseMemo = (itemType, itemId) => /** * Get a quote for paying with a non-USDC asset via path payment * POST /api/stellar/payment/quote + * + * NOTE: path payments always settle in USDC regardless of the item's own + * currency - that mechanism is issue #27's scope. Items priced in a + * non-USDC currency (e.g. EURC) are rejected here rather than silently + * treating item.price as a USDC amount. */ export const getQuote = async (req, res) => { try { @@ -123,6 +130,14 @@ export const getQuote = async (req, res) => { }); } + const itemAssetCode = resolveItemCurrency(item); + if (itemAssetCode !== "USDC") { + return res.status(400).json({ + success: false, + message: `Path payment quotes are only available for USDC-priced items. This item is priced in ${itemAssetCode}; pay directly in ${itemAssetCode} instead.`, + }); + } + if (sendAssetCode && !sendAssetIssuer && sendAssetCode !== "XLM" && sendAssetCode !== "native") { return res.status(400).json({ success: false, @@ -203,9 +218,9 @@ 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. + * Run pre-flight payment safety checks (destination existence, trustline + * for the item's currency, 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) => { @@ -245,6 +260,14 @@ export const getPaymentPreflight = async (req, res) => { }); } + const assetCode = resolveItemCurrency(item); + if (!isAssetSupported(assetCode)) { + return res.status(400).json({ + success: false, + message: `This item is priced in an unsupported asset (${assetCode}). Supported: ${getSupportedCodes().join(", ")}`, + }); + } + const memo = buildPurchaseMemo(itemType, itemId); const feeSplitPreview = settlementMode === "direct" ? calculateFeeSplit(item.price) : null; @@ -255,6 +278,7 @@ export const getPaymentPreflight = async (req, res) => { amount: item.price.toString(), memo, operationCount: feeSplitPreview ? 2 : 1, + assetCode, }); res.status(200).json({ @@ -284,7 +308,6 @@ export const initializePayment = async (req, res) => { const buyerId = req.user._id; const { itemType, itemId, buyerWallet, sendAsset: sendAssetInput, sendMax, path: pathInput } = req.body; - // Validate item type if (!["book", "course"].includes(itemType)) { await session.abortTransaction(); return res.status(400).json({ @@ -293,7 +316,6 @@ export const initializePayment = async (req, res) => { }); } - // Get buyer info const buyer = await User.findById(buyerId).session(session); if (!buyer?.stellarWallet?.publicKey) { await session.abortTransaction(); @@ -303,7 +325,6 @@ export const initializePayment = async (req, res) => { }); } - // Verify wallet matches if (buyer.stellarWallet.publicKey !== buyerWallet) { await session.abortTransaction(); return res.status(400).json({ @@ -312,7 +333,6 @@ export const initializePayment = async (req, res) => { }); } - // Get item details and resolve the settlement destination const resolved = await resolvePaymentDestination({ itemType, itemId, session }); if (resolved.error) { await session.abortTransaction(); @@ -324,7 +344,6 @@ export const initializePayment = async (req, res) => { const { item, creator, destinationPublicKey, settlementMode } = resolved; - // Check if item is free if (!item.price || item.price === 0) { await session.abortTransaction(); return res.status(400).json({ @@ -333,7 +352,15 @@ export const initializePayment = async (req, res) => { }); } - // Check if already purchased + const itemAssetCode = resolveItemCurrency(item); + if (!isAssetSupported(itemAssetCode)) { + await session.abortTransaction(); + return res.status(400).json({ + success: false, + message: `This item is priced in an unsupported asset (${itemAssetCode}). Supported: ${getSupportedCodes().join(", ")}`, + }); + } + const purchasedArray = itemType === "book" ? buyer.purchasedBooks : buyer.purchasedCourses; const idField = itemType === "book" ? "bookId" : "courseId"; @@ -349,7 +376,6 @@ export const initializePayment = async (req, res) => { }); } - // Check for existing pending transaction const existingTx = await Transaction.findOne({ buyer: buyerId, itemType, @@ -366,15 +392,30 @@ export const initializePayment = async (req, res) => { }); } - // Generate unique memo for this transaction 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; let paymentTx; let sep7Uri = null; + // Currency actually settled on-chain: path payments always settle in + // USDC (issue #27's mechanism); direct payments settle in the item's + // own currency. Set explicitly in each branch below rather than + // defaulting, so it's clear neither branch can silently fall through. + let settledAssetCode; if (isPathPayment) { + // Path payments always settle in USDC. Guard against an item priced + // in a different asset, since destAmount below is item.price and + // would otherwise be misinterpreted as a USDC amount. + if (itemAssetCode !== "USDC") { + await session.abortTransaction(); + return res.status(400).json({ + success: false, + message: `Path payments are only available for USDC-priced items. This item is priced in ${itemAssetCode}; pay directly in ${itemAssetCode} instead.`, + }); + } + settledAssetCode = "USDC"; + const sendAsset = sendAssetInput.issuer ? new StellarSdk.Asset(sendAssetInput.code, sendAssetInput.issuer) : StellarSdk.Asset.native(); @@ -398,6 +439,7 @@ export const initializePayment = async (req, res) => { applyPlatformFee: settlementMode === "direct", }); } else { + settledAssetCode = itemAssetCode; const feeSplitPreview = settlementMode === "direct" ? calculateFeeSplit(item.price) : null; @@ -407,6 +449,7 @@ export const initializePayment = async (req, res) => { amount: item.price.toString(), memo, operationCount: feeSplitPreview ? 2 : 1, + assetCode: itemAssetCode, }); if (!preflight.ok) { @@ -424,18 +467,20 @@ export const initializePayment = async (req, res) => { amount: item.price.toString(), memo, applyPlatformFee: settlementMode === "direct", + assetCode: itemAssetCode, }); sep7Uri = buildSep7Uri({ destination: destinationPublicKey, amount: item.price.toString(), memo, + assetCode: itemAssetCode, }); } const feeSplit = paymentTx.feeSplit; + const settledAssetConfig = getAssetConfig(settledAssetCode); - // Create pending transaction record const transaction = new Transaction({ buyer: buyerId, buyerWallet: buyer.stellarWallet.publicKey, @@ -446,6 +491,8 @@ export const initializePayment = async (req, res) => { itemTypeModel: itemType === "book" ? "Book" : "Course", itemTitle: item.title, amount: item.price.toString(), + currency: settledAssetCode, + assetIssuer: settledAssetConfig?.issuer || null, network: NETWORK, status: "pending", settlement: settlementMode, @@ -529,7 +576,6 @@ export const submitPayment = async (req, res) => { }); } - // Find the pending transaction const transaction = await Transaction.findOne({ _id: transactionId, buyer: buyerId, @@ -544,18 +590,15 @@ export const submitPayment = async (req, res) => { }); } - // Update status to submitted transaction.status = "submitted"; transaction.submittedAt = new Date(); await transaction.save({ session }); paymentsSubmitted.inc({ type: "purchase" }); - // Submit to Stellar network let result; try { result = await submitTransaction(signedXdr); } catch (stellarError) { - // Handle Stellar submission errors transaction.status = "failed"; transaction.failureReason = stellarError.message; await transaction.save({ session }); @@ -571,8 +614,6 @@ export const submitPayment = async (req, res) => { }); } - // Verify on-chain that the creator (and platform, when a fee was applied) - // actually received the expected USDC amounts const expectedPayments = transaction.platformFee?.platformAmount ? [ { @@ -593,7 +634,8 @@ export const submitPayment = async (req, res) => { const verification = await verifyPaymentOperations( result.hash, - expectedPayments + expectedPayments, + transaction.currency || "USDC" ); if (!verification.verified) { @@ -602,16 +644,26 @@ export const submitPayment = async (req, res) => { transaction.status = "retrying"; transaction.failureReason = verification.reason; await transaction.save({ session }); - await enqueue( - "verifyPaymentOnChain", - { transactionId: transaction._id.toString() }, - { - attempts: 5, - backoffMs: 1000, - idempotencyKey: `verify:${result.hash}`, - session, - } - ); + try { + await enqueue( + "verifyPaymentOnChain", + { transactionId: transaction._id.toString() }, + { + attempts: 5, + backoffMs: 1000, + idempotencyKey: `verify:${result.hash}`, + session, + } + ); + } catch (enqueueErr) { + // Don't let a queue outage roll back the on-chain-verified + // "retrying" status - a sweeper can still reconcile this later + // from stellarTxHash even if scheduling the retry job failed. + logger.error( + `Failed to enqueue verifyPaymentOnChain for transaction ${transaction._id}:`, + enqueueErr + ); + } await session.commitTransaction(); return res.status(202).json({ success: true, @@ -638,7 +690,6 @@ export const submitPayment = async (req, res) => { }); } - // Update transaction with Stellar response transaction.stellarTxHash = result.hash; transaction.stellarLedger = result.ledger; transaction.status = "confirmed"; @@ -646,10 +697,8 @@ export const submitPayment = async (req, res) => { await transaction.save({ session }); paymentsConfirmed.inc({ type: "purchase" }); - // Record earnings for educator balance & ledger (idempotent per stellarTxHash) await recordSaleEarnings(transaction, { session }); - // Grant access to the purchased item const buyer = await User.findById(buyerId).session(session); if (transaction.itemType === "book") { @@ -669,7 +718,6 @@ export const submitPayment = async (req, res) => { buyer.stat.coursesEnrolled = (buyer.stat.coursesEnrolled || 0) + 1; } - // Also add to course's enrolledUsers await Course.findByIdAndUpdate( transaction.itemId, { $addToSet: { enrolledUsers: buyerId } }, @@ -678,16 +726,26 @@ export const submitPayment = async (req, res) => { } await buyer.save({ session }); - await enqueue( - "generateReceipt", - { transactionId: transaction._id.toString() }, - { - attempts: 5, - backoffMs: 1000, - idempotencyKey: `receipt:${result.hash}`, - session, - } - ); + try { + await enqueue( + "generateReceipt", + { transactionId: transaction._id.toString() }, + { + attempts: 5, + backoffMs: 1000, + idempotencyKey: `receipt:${result.hash}`, + session, + } + ); + } catch (enqueueErr) { + // Don't let a queue outage roll back a payment that's already + // confirmed on-chain (earnings recorded, access granted) - the + // receipt can be regenerated later; the purchase itself must stand. + logger.error( + `Failed to enqueue generateReceipt for transaction ${transaction._id}:`, + enqueueErr + ); + } await session.commitTransaction(); logger.info( @@ -741,7 +799,6 @@ export const getTransactionHistory = async (req, res) => { const total = await Transaction.countDocuments(query); - // Add explorer URLs const transactionsWithUrls = transactions.map((tx) => ({ ...tx.toObject(), explorerUrl: @@ -790,7 +847,6 @@ export const getTransaction = async (req, res) => { }); } - // If confirmed, verify on Stellar let stellarVerification = null; if (transaction.status === "confirmed") { try { @@ -867,4 +923,4 @@ export const cancelTransaction = async (req, res) => { message: "Failed to cancel transaction", }); } -}; +}; \ No newline at end of file diff --git a/src/controllers/stellar/refundController.js b/src/controllers/stellar/refundController.js index 1540e181..e85bc9c6 100644 --- a/src/controllers/stellar/refundController.js +++ b/src/controllers/stellar/refundController.js @@ -8,8 +8,9 @@ import Refund from "../../models/Refund.js"; import { buildReversePaymentTransaction, submitTransaction, - verifyTransaction, + verifyPaymentOperations, } from "../../services/stellar/stellarService.js"; +import { isAssetSupported, getSupportedCodes } from "../../config/assets.js"; import logger from "../../config/logger.js"; const REFUND_WINDOW_DAYS = parseInt(process.env.REFUND_WINDOW_DAYS || "14", 10); @@ -31,7 +32,6 @@ export const requestRefund = async (req, res) => { }); } - // Find original transaction const transaction = await Transaction.findById(transactionId); if (!transaction) { return res.status(404).json({ @@ -40,7 +40,6 @@ export const requestRefund = async (req, res) => { }); } - // Guard: Only the original buyer if (transaction.buyer.toString() !== buyerId.toString()) { return res.status(403).json({ success: false, @@ -48,7 +47,6 @@ export const requestRefund = async (req, res) => { }); } - // Guard: Only confirmed transactions if (transaction.status !== "confirmed") { return res.status(400).json({ success: false, @@ -56,7 +54,6 @@ export const requestRefund = async (req, res) => { }); } - // Guard: Within configurable refund window const confirmedTime = new Date( transaction.confirmedAt || transaction.updatedAt ).getTime(); @@ -68,7 +65,6 @@ export const requestRefund = async (req, res) => { }); } - // Guard: Idempotency - check for existing open/active refund request const existingRefund = await Refund.findOne({ originalTransaction: transaction._id, status: { $in: ["requested", "approved", "submitted", "confirmed", "disputed"] }, @@ -82,7 +78,6 @@ export const requestRefund = async (req, res) => { }); } - // Create refund request const refund = await Refund.create({ originalTransaction: transaction._id, buyer: transaction.buyer, @@ -96,7 +91,6 @@ export const requestRefund = async (req, res) => { expiresAt: new Date(Date.now() + windowMs), }); - // Cross-link on transaction transaction.refund = refund._id; await transaction.save(); @@ -133,7 +127,6 @@ export const buildRefundXdr = async (req, res) => { }); } - // Guard: Only the educator if (refund.educator.toString() !== educatorId.toString()) { return res.status(403).json({ success: false, @@ -141,7 +134,6 @@ export const buildRefundXdr = async (req, res) => { }); } - // Guard: Status must be requested if (refund.status !== "requested") { return res.status(400).json({ success: false, @@ -149,7 +141,17 @@ export const buildRefundXdr = async (req, res) => { }); } - // Fetch buyer & educator wallet info + // Validate the refund's currency before resolving an asset from it, so + // an unsupported/unknown currency returns a clean 400 instead of a 500 + // from buildReversePaymentTransaction's resolveAsset call. + const refundAssetCode = refund.currency || "USDC"; + if (!isAssetSupported(refundAssetCode)) { + return res.status(400).json({ + success: false, + message: `Refund currency ${refundAssetCode} is not supported. Supported: ${getSupportedCodes().join(", ")}`, + }); + } + const buyer = await User.findById(refund.buyer); const educator = await User.findById(refund.educator); @@ -165,12 +167,15 @@ export const buildRefundXdr = async (req, res) => { const originalTxHash = refund.originalTransaction?.stellarTxHash || ""; - // Build reverse payment transaction (educator -> buyer) + // Build reverse payment transaction (educator -> buyer), in the same + // asset the original payment was made in, so an EURC/XLM purchase is + // refunded in EURC/XLM rather than always defaulting to USDC. const result = await buildReversePaymentTransaction({ sourcePublicKey: educatorWallet, destinationPublicKey: buyerWallet, amount: refund.amount, originalTxHash, + assetCode: refundAssetCode, }); refund.status = "approved"; @@ -219,7 +224,6 @@ export const submitRefund = async (req, res) => { }); } - // Guard: Only the educator if (refund.educator.toString() !== educatorId.toString()) { return res.status(403).json({ success: false, @@ -227,7 +231,6 @@ export const submitRefund = async (req, res) => { }); } - // Guard: Enforce transition order (must be approved first) if (refund.status !== "approved") { return res.status(400).json({ success: false, @@ -235,6 +238,21 @@ export const submitRefund = async (req, res) => { }); } + // Fetch the buyer once up front: we need their wallet as the expected + // destination for on-chain verification below, and we reuse the same + // buyer document for access revocation later instead of looking it up + // a second time. + const buyer = await User.findById(refund.buyer); + const buyerWallet = buyer?.stellarWallet?.publicKey; + if (!buyerWallet) { + refund.status = "failed"; + await refund.save(); + return res.status(400).json({ + success: false, + message: "Missing buyer wallet information; cannot verify refund payment", + }); + } + // Submit transaction to Stellar network let submissionResult; try { @@ -248,32 +266,37 @@ export const submitRefund = async (req, res) => { }); } - // On-Chain Truth Verification via Horizon - const verification = await verifyTransaction(submissionResult.hash); - if (!verification.exists || !verification.successful) { + // On-Chain Truth Verification via Horizon: validate the actual reverse + // payment operation (destination, amount, and asset), not just that a + // successful transaction with this hash exists on Horizon. + const verification = await verifyPaymentOperations( + submissionResult.hash, + [{ destination: buyerWallet, amount: refund.amount }], + refund.currency || "USDC" + ); + if (!verification.verified) { refund.status = "failed"; await refund.save(); return res.status(400).json({ success: false, - message: "Reverse payment transaction could not be verified on Horizon", + message: `Reverse payment could not be verified on Horizon: ${verification.reason || "payment mismatch"}`, }); } // Access Revocation — sequential writes (no session/transaction required; // the Stellar on-chain verification above is the source of truth) try { - const buyer = await User.findById(refund.buyer); - if (refund.itemType === "course") { - // Remove course from buyer's purchased list + // Remove course from buyer's purchased list. purchasedCourses + // entries are subdocuments ({ courseId, purchaseDate }), so match + // on the courseId field rather than the subdocument itself. if (buyer) { buyer.purchasedCourses = (buyer.purchasedCourses || []).filter( - (cId) => cId.toString() !== refund.itemId.toString() + (entry) => entry.courseId?.toString() !== refund.itemId.toString() ); await buyer.save(); } - // Remove buyer from Course.enrolledUsers const course = await Course.findById(refund.itemId); if (course) { course.enrolledUsers = (course.enrolledUsers || []).filter( @@ -282,16 +305,15 @@ export const submitRefund = async (req, res) => { await course.save(); } } else if (refund.itemType === "book") { - // Remove book from buyer's purchased list + // Same subdocument shape as above: match on bookId, not the entry. if (buyer) { buyer.purchasedBooks = (buyer.purchasedBooks || []).filter( - (bId) => bId.toString() !== refund.itemId.toString() + (entry) => entry.bookId?.toString() !== refund.itemId.toString() ); await buyer.save(); } } - // Update refund & transaction status refund.status = "confirmed"; refund.refundTxHash = submissionResult.hash; refund.refundLedger = submissionResult.ledger; @@ -487,4 +509,4 @@ export const arbitrateDispute = async (req, res) => { message: error.message || "Failed to arbitrate dispute", }); } -}; +}; \ No newline at end of file diff --git a/src/controllers/stellar/walletController.js b/src/controllers/stellar/walletController.js index 2767a23b..3e75b421 100644 --- a/src/controllers/stellar/walletController.js +++ b/src/controllers/stellar/walletController.js @@ -16,7 +16,6 @@ export const connectWallet = async (req, res) => { const userId = req.user._id; const { publicKey } = req.body; - // Validate public key format if (!publicKey || !isValidPublicKey(publicKey)) { return res.status(400).json({ success: false, @@ -24,7 +23,6 @@ export const connectWallet = async (req, res) => { }); } - // Check if wallet is already connected to another user const existingUser = await User.findOne({ "stellarWallet.publicKey": publicKey, _id: { $ne: userId }, @@ -37,10 +35,11 @@ export const connectWallet = async (req, res) => { }); } - // Verify account exists on Stellar network and get balance info + // Verify account exists on Stellar network and get balance/trustline info + // (accountInfo now includes per-asset balances/trustlines from the + // registry, e.g. { balances: { USDC, EURC }, trustlines: { USDC, EURC } }) const accountInfo = await getAccountBalance(publicKey); - // Update user with wallet info const user = await User.findByIdAndUpdate( userId, { @@ -105,6 +104,9 @@ export const disconnectWallet = async (req, res) => { /** * Get wallet balance for any public key * GET /api/stellar/wallet/balance/:publicKey + * Response now includes balances/trustlines per registry asset (USDC, + * EURC, XLM, ...) alongside the back-compat usdcBalance/hasTrustline + * fields, so the UI can prompt e.g. "add a EURC trustline" when needed. */ export const getWalletBalance = async (req, res) => { try { @@ -148,7 +150,7 @@ export const getMyWallet = async (req, res) => { }); } - // Get live balance from Stellar network + // Get live balance/trustlines from Stellar network (per-asset) const balance = await getAccountBalance(user.stellarWallet.publicKey); res.status(200).json({ @@ -199,4 +201,4 @@ export const checkUserWallet = async (req, res) => { message: "Failed to check wallet status", }); } -}; +}; \ No newline at end of file diff --git a/src/models/Book.js b/src/models/Book.js index a3e55ce1..fb48f579 100644 --- a/src/models/Book.js +++ b/src/models/Book.js @@ -1,4 +1,5 @@ import mongoose from "mongoose"; +import { getSupportedCodes } from "../config/assets.js"; const bookSchema = new mongoose.Schema({ title: { @@ -15,6 +16,12 @@ const bookSchema = new mongoose.Schema({ type: Number, default: 0, }, + // Asset the price is denominated in; existing rows default to USDC. + currency: { + type: String, + default: "USDC", + enum: getSupportedCodes(), + }, readCount: { type: Number, default: 0, @@ -60,4 +67,4 @@ bookSchema.index({ title: "text", description: "text", category: "text" }, { wei const Book = mongoose.model("Book", bookSchema); -export default Book; +export default Book; \ No newline at end of file diff --git a/src/models/Course.js b/src/models/Course.js index db43f1cf..69aa4d57 100644 --- a/src/models/Course.js +++ b/src/models/Course.js @@ -1,4 +1,5 @@ import mongoose from "mongoose"; +import { getSupportedCodes } from "../config/assets.js"; const courseSchema = new mongoose.Schema( { @@ -25,6 +26,12 @@ const courseSchema = new mongoose.Schema( type: Number, default: 0, }, + // Asset the price is denominated in; existing rows default to USDC. + currency: { + type: String, + default: "USDC", + enum: getSupportedCodes(), + }, reviews: [ { user: { @@ -49,5 +56,4 @@ const courseSchema = new mongoose.Schema( ); courseSchema.index({ title: "text", description: "text", category: "text" }, { weights: { title: 5 } }); - export default mongoose.model("Course", courseSchema); diff --git a/src/models/Transaction.js b/src/models/Transaction.js index 9321a42e..f8b52be2 100644 --- a/src/models/Transaction.js +++ b/src/models/Transaction.js @@ -1,5 +1,6 @@ // models/Transaction.js import mongoose from "mongoose"; +import { getSupportedCodes } from "../config/assets.js"; const transactionSchema = new mongoose.Schema( { @@ -13,7 +14,6 @@ const transactionSchema = new mongoose.Schema( stellarLedger: { type: Number, }, - // Transaction kind: item purchase or sadaqah donation type: { type: String, @@ -21,7 +21,6 @@ const transactionSchema = new mongoose.Schema( default: "purchase", index: true, }, - // Parties involved buyer: { type: mongoose.Schema.Types.ObjectId, @@ -45,7 +44,6 @@ const transactionSchema = new mongoose.Schema( type: String, required: true, }, - // Item being purchased (not applicable to donations) itemType: { type: String, @@ -74,16 +72,23 @@ const transactionSchema = new mongoose.Schema( return this.type !== "donation"; }, }, - // Payment details amount: { type: String, // Store as string to preserve precision required: true, }, + // Widened from a USDC-only enum to the full asset registry; existing + // rows with no currency set default to "USDC" so nothing breaks. currency: { type: String, default: "USDC", - enum: ["USDC"], + enum: getSupportedCodes(), + }, + // Issuer for the settled currency (null for native XLM). Separate from + // sendAsset below, which is the asset the *buyer* sent in a path payment. + assetIssuer: { + type: String, + default: null, }, sendAsset: { code: { type: String }, @@ -95,7 +100,6 @@ const transactionSchema = new mongoose.Schema( enum: ["testnet", "mainnet"], required: true, }, - // Platform fee split (only set when a fee was applied at build time) platformFee: { feePercent: Number, @@ -103,7 +107,6 @@ const transactionSchema = new mongoose.Schema( platformAmount: String, // Stored as string to preserve precision creatorAmount: String, }, - // Settlement mode: direct payment to creator or platform collect for payouts settlement: { type: String, @@ -111,7 +114,6 @@ const transactionSchema = new mongoose.Schema( default: "direct", index: true, }, - // Status tracking status: { type: String, @@ -119,14 +121,12 @@ const transactionSchema = new mongoose.Schema( default: "pending", index: true, }, - // Refund linkage refund: { type: mongoose.Schema.Types.ObjectId, ref: "Refund", default: null, }, - // Error handling failureReason: { type: String, @@ -135,7 +135,6 @@ const transactionSchema = new mongoose.Schema( type: Number, default: 0, }, - // Timestamps submittedAt: Date, confirmedAt: Date, @@ -146,12 +145,10 @@ const transactionSchema = new mongoose.Schema( }, { timestamps: true } ); - // Indexes for efficient queries transactionSchema.index({ buyer: 1, status: 1 }); transactionSchema.index({ creator: 1, status: 1 }); transactionSchema.index({ itemType: 1, itemId: 1 }); transactionSchema.index({ type: 1, status: 1, createdAt: -1 }); // Donation stats transactionSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); // TTL for expired pending - -export default mongoose.model("Transaction", transactionSchema); +export default mongoose.model("Transaction", transactionSchema); \ No newline at end of file diff --git a/src/services/stellar/multiAsset.test.js b/src/services/stellar/multiAsset.test.js new file mode 100644 index 00000000..03758066 --- /dev/null +++ b/src/services/stellar/multiAsset.test.js @@ -0,0 +1,152 @@ +import { jest } from "@jest/globals"; +import * as StellarSdk from "@stellar/stellar-sdk"; +import { getAssetConfig } from "../../config/assets.js"; + +const TEST_PUBLIC_KEY = StellarSdk.Keypair.random().publicKey(); + +const makeFakeAccount = (publicKey, balances, sequence = "12345") => { + const account = new StellarSdk.Account(publicKey, sequence); + account.balances = balances; + account.subentry_count = 0; + account.data_attr = {}; + return account; +}; + +const mockServer = { + loadAccount: jest.fn(async () => + makeFakeAccount(TEST_PUBLIC_KEY, [{ asset_type: "native", balance: "1000" }]) + ), +}; + +const mockExecute = jest.fn(async (fn) => fn(mockServer)); + +jest.unstable_mockModule("./horizonClient.js", () => ({ + client: { execute: mockExecute, endpoints: [{ server: mockServer }] }, +})); + +const { + buildPaymentTransaction, + hasTrustline, + hasUsdcTrustline, + getAccountBalance, + USDC, +} = await import("./stellarService.js"); + +describe("buildPaymentTransaction - native asset (XLM)", () => { + it("builds a payment op with a native asset and no issuer", async () => { + const destination = StellarSdk.Keypair.random().publicKey(); + const result = await buildPaymentTransaction({ + sourcePublicKey: TEST_PUBLIC_KEY, + destinationPublicKey: destination, + amount: "50", + assetCode: "XLM", + }); + + const parsed = StellarSdk.TransactionBuilder.fromXDR( + result.xdr, + StellarSdk.Networks.TESTNET + ); + const op = parsed.operations[0]; + expect(op.type).toBe("payment"); + expect(op.asset.isNative()).toBe(true); + expect(op.destination).toBe(destination); + expect(result.assetCode).toBe("XLM"); + }); +}); + +describe("buildPaymentTransaction - USDC default path (unchanged)", () => { + it("builds a payment op in USDC when assetCode is omitted", async () => { + const destination = StellarSdk.Keypair.random().publicKey(); + const result = await buildPaymentTransaction({ + sourcePublicKey: TEST_PUBLIC_KEY, + destinationPublicKey: destination, + amount: "10", + }); + + const parsed = StellarSdk.TransactionBuilder.fromXDR( + result.xdr, + StellarSdk.Networks.TESTNET + ); + const op = parsed.operations[0]; + expect(op.asset.getCode()).toBe("USDC"); + expect(op.asset.getIssuer()).toBe(USDC.getIssuer()); + expect(result.assetCode).toBe("USDC"); + }); +}); + +describe("trustline check for a non-USDC asset (EURC)", () => { + const eurcConfig = getAssetConfig("EURC", "testnet"); + + it("reports no EURC trustline when the account only holds USDC", async () => { + mockServer.loadAccount.mockResolvedValueOnce( + makeFakeAccount(TEST_PUBLIC_KEY, [ + { asset_type: "native", balance: "1000" }, + { + asset_type: "credit_alphanum4", + asset_code: "USDC", + asset_issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + balance: "20", + }, + ]) + ); + + const result = await hasTrustline(TEST_PUBLIC_KEY, "EURC"); + expect(result).toBe(false); + }); + + it("reports an EURC trustline when the account holds an EURC balance line", async () => { + mockServer.loadAccount.mockResolvedValueOnce( + makeFakeAccount(TEST_PUBLIC_KEY, [ + { asset_type: "native", balance: "1000" }, + { + asset_type: "credit_alphanum4", + asset_code: "EURC", + asset_issuer: eurcConfig.issuer, + balance: "5", + }, + ]) + ); + + const result = await hasTrustline(TEST_PUBLIC_KEY, "EURC"); + expect(result).toBe(true); + }); + + it("hasUsdcTrustline (back-compat wrapper) still checks USDC specifically", async () => { + mockServer.loadAccount.mockResolvedValueOnce( + makeFakeAccount(TEST_PUBLIC_KEY, [ + { asset_type: "native", balance: "1000" }, + { + asset_type: "credit_alphanum4", + asset_code: "USDC", + asset_issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + balance: "20", + }, + ]) + ); + + expect(await hasUsdcTrustline(TEST_PUBLIC_KEY)).toBe(true); + }); +}); + +describe("getAccountBalance - multi-asset shape", () => { + it("returns per-asset balances and trustlines alongside back-compat fields", async () => { + mockServer.loadAccount.mockResolvedValueOnce( + makeFakeAccount(TEST_PUBLIC_KEY, [ + { asset_type: "native", balance: "1000" }, + { + asset_type: "credit_alphanum4", + asset_code: "USDC", + asset_issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + balance: "20", + }, + ]) + ); + + const result = await getAccountBalance(TEST_PUBLIC_KEY); + expect(result.usdcBalance).toBe("20"); + expect(result.hasTrustline).toBe(true); + expect(result.balances.USDC).toBe("20"); + expect(result.balances.EURC).toBe("0"); + expect(result.trustlines.EURC).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/services/stellar/stellarService.js b/src/services/stellar/stellarService.js index 255aafc5..7f1b6600 100644 --- a/src/services/stellar/stellarService.js +++ b/src/services/stellar/stellarService.js @@ -2,6 +2,12 @@ import * as StellarSdk from "@stellar/stellar-sdk"; import logger from "../../config/logger.js"; import { observeHorizonDuration } from "../../config/metrics.js"; +import { + getAssetConfig, + getRegistry, + getDefaultAssetCode, + getSupportedCodes, +} from "../../config/assets.js"; import { client } from "./horizonClient.js"; @@ -11,13 +17,33 @@ const networkPassphrase = ? StellarSdk.Networks.PUBLIC : StellarSdk.Networks.TESTNET; -const USDC_ISSUER = - NETWORK === "mainnet" - ? "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" - : "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; - +// Back-compat: USDC / USDC_ISSUER are now derived from the registry +// instead of being hardcoded, but keep the same exported shape so +// existing callers (and the path-payment flow, out of scope for #60) +// keep working unchanged. +const USDC_CONFIG = getAssetConfig("USDC", NETWORK); +const USDC_ISSUER = USDC_CONFIG.issuer; const USDC = new StellarSdk.Asset("USDC", USDC_ISSUER); +const DEFAULT_ASSET_CODE = getDefaultAssetCode(NETWORK); + +/** + * Resolve a StellarSdk.Asset instance from a registry asset code. + * Native assets (issuer === null, e.g. XLM) use Asset.native(). + * Throws a clear error if the code isn't supported on this network. + */ +export const resolveAsset = (assetCode = DEFAULT_ASSET_CODE) => { + const config = getAssetConfig(assetCode, NETWORK); + if (!config) { + throw new Error( + `Unsupported asset code: ${assetCode}. Supported: ${getSupportedCodes(NETWORK).join(", ")}` + ); + } + return config.issuer + ? new StellarSdk.Asset(config.code, config.issuer) + : StellarSdk.Asset.native(); +}; + const DONATION_WALLET_PUBLIC_KEY = process.env.DONATION_WALLET_PUBLIC_KEY || ""; const PLATFORM_WALLET_PUBLIC_KEY = process.env.PLATFORM_WALLET_PUBLIC_KEY || ""; @@ -49,11 +75,6 @@ async function timedHorizonCall(operation, fn) { } } -/** - * Convert a decimal amount (string or number) to stroops (BigInt, 7 decimals) - * @param {string|number} amount - The amount to convert - * @returns {BigInt} - Amount in stroops - */ export const toStroops = (amount) => { const [whole, frac = ""] = amount.toString().split("."); return ( @@ -62,11 +83,6 @@ export const toStroops = (amount) => { ); }; -/** - * Convert stroops (BigInt) back to a decimal amount string - * @param {BigInt} stroops - Amount in stroops - * @returns {string} - Decimal amount string - */ export const fromStroops = (stroops) => { const whole = stroops / STROOPS_PER_UNIT; const frac = (stroops % STROOPS_PER_UNIT) @@ -86,6 +102,10 @@ const applySlippageStroops = (stroops, bps) => { return stroops + (stroops * BigInt(bps)) / 10000n; }; +// NOTE: findPaymentPaths / buildPathPaymentTransaction stay USDC-settled +// on purpose. Issue #60 (this refactor) covers settling natively in any +// registry asset; path-payment settlement into a chosen non-USDC asset is +// issue #27's scope, to avoid duplicating that work. export const findPaymentPaths = async (sendAsset, destAmount) => { try { const records = await timedHorizonCall("strictReceivePaths", () => @@ -219,14 +239,23 @@ export const calculateFeeSplit = ( }; }; -export const buildSep7Uri = ({ destination, amount, memo }) => { +export const buildSep7Uri = ({ destination, amount, memo, assetCode = DEFAULT_ASSET_CODE }) => { + const config = getAssetConfig(assetCode, NETWORK); + if (!config) { + throw new Error(`Unsupported asset code: ${assetCode}`); + } + const params = new URLSearchParams({ destination, amount: amount.toString(), - asset_code: "USDC", - asset_issuer: USDC_ISSUER, }); + // Native XLM has no asset_code/asset_issuer in SEP-7 (omission means XLM). + if (config.issuer) { + params.set("asset_code", config.code); + params.set("asset_issuer", config.issuer); + } + if (memo) { params.set("memo", memo); params.set("memo_type", "MEMO_TEXT"); @@ -244,16 +273,35 @@ export const isValidPublicKey = (publicKey) => { } }; +/** + * Summarize an account's XLM balance plus per-asset balances/trustlines + * for every issued asset in the registry (native XLM is handled + * separately since it never needs a trustline). + */ const parseAccountSummary = (account) => { - const usdcBalance = account.balances?.find( - (b) => b.asset_code === "USDC" && b.asset_issuer === USDC_ISSUER - ); + const registry = getRegistry(NETWORK); + const balances = {}; + const trustlines = {}; + + for (const [code, cfg] of Object.entries(registry)) { + if (!cfg.issuer) continue; // native asset, no trustline concept + const found = account.balances?.find( + (b) => b.asset_code === cfg.code && b.asset_issuer === cfg.issuer + ); + balances[code] = found?.balance || "0"; + trustlines[code] = !!found; + } + const xlmBalance = account.balances?.find((b) => b.asset_type === "native"); return { xlmBalance: xlmBalance?.balance || "0", - usdcBalance: usdcBalance?.balance || "0", - hasTrustline: !!usdcBalance, + balances, + trustlines, + // Back-compat fields: existing callers expect a single USDC balance + // and a single hasTrustline flag. + usdcBalance: balances.USDC || "0", + hasTrustline: !!trustlines.USDC, subentryCount: account.subentry_count ?? 0, }; }; @@ -270,14 +318,26 @@ export const getAccountBalance = async (publicKey) => { xlmBalance: summary.xlmBalance, usdcBalance: summary.usdcBalance, hasTrustline: summary.hasTrustline, + balances: summary.balances, + trustlines: summary.trustlines, }; } catch (error) { if (error.response?.status === 404) { + const registry = getRegistry(NETWORK); + const balances = {}; + const trustlines = {}; + for (const [code, cfg] of Object.entries(registry)) { + if (!cfg.issuer) continue; + balances[code] = "0"; + trustlines[code] = false; + } return { exists: false, xlmBalance: "0", usdcBalance: "0", hasTrustline: false, + balances, + trustlines, }; } logger.error("Error fetching account balance:", error); @@ -285,9 +345,6 @@ 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) => { @@ -310,8 +367,6 @@ export const PREFLIGHT_REASON_CODES = Object.freeze({ 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) => { @@ -328,10 +383,9 @@ const loadAccountOrNull = async (publicKey) => { }; /** - * Validate a prospective USDC payment before an unsigned XDR is built, so the + * Validate a prospective 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. + * assetCode defaults to the registry default (USDC) for back-compat. */ export const preflightPayment = async ({ sourcePublicKey, @@ -339,9 +393,19 @@ export const preflightPayment = async ({ amount, memo, operationCount = 1, + assetCode = DEFAULT_ASSET_CODE, }) => { const reasons = []; const warnings = []; + const assetConfig = getAssetConfig(assetCode, NETWORK); + + if (!assetConfig) { + reasons.push({ + code: "unsupported_asset", + message: `Asset ${assetCode} is not supported. Supported: ${getSupportedCodes(NETWORK).join(", ")}`, + }); + return { ok: false, reasons, warnings }; + } const [sourceAccount, destinationAccount] = await Promise.all([ loadAccountOrNull(sourcePublicKey), @@ -356,12 +420,16 @@ export const preflightPayment = async ({ } else { const summary = parseAccountSummary(sourceAccount); const requiredStroops = toStroops(amount); - const availableStroops = toStroops(summary.usdcBalance); + // Native XLM balance being spent is checked against xlmBalance; issued + // assets (USDC, EURC, ...) are checked against their own balance. + const availableStroops = assetConfig.issuer + ? toStroops(summary.balances[assetCode] || "0") + : toStroops(summary.xlmBalance); if (availableStroops < requiredStroops) { reasons.push({ code: PREFLIGHT_REASON_CODES.SOURCE_INSUFFICIENT_BALANCE, - message: "Your wallet does not hold enough USDC to complete this payment.", + message: `Your wallet does not hold enough ${assetCode} to complete this payment.`, }); } @@ -386,12 +454,14 @@ export const preflightPayment = async ({ }); } else { const summary = parseAccountSummary(destinationAccount); + const hasRequiredTrustline = assetConfig.issuer + ? !!summary.trustlines[assetCode] + : true; // native XLM never needs a trustline - if (!summary.hasTrustline) { + if (!hasRequiredTrustline) { 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.", + message: `Recipient needs to add a ${assetCode} trustline to their wallet before they can receive this payment.`, }); } @@ -417,8 +487,10 @@ export const buildPaymentTransaction = async ({ amount, memo, applyPlatformFee = false, + assetCode = DEFAULT_ASSET_CODE, }) => { try { + const asset = resolveAsset(assetCode); const sourceAccount = await timedHorizonCall("loadAccount", () => client.execute(server => server.loadAccount(sourcePublicKey)) ); @@ -435,14 +507,14 @@ export const buildPaymentTransaction = async ({ .addOperation( StellarSdk.Operation.payment({ destination: destinationPublicKey, - asset: USDC, + asset, amount: feeSplit.creatorAmount, }) ) .addOperation( StellarSdk.Operation.payment({ destination: feeSplit.platformWallet, - asset: USDC, + asset, amount: feeSplit.platformAmount, }) ); @@ -450,7 +522,7 @@ export const buildPaymentTransaction = async ({ builder.addOperation( StellarSdk.Operation.payment({ destination: destinationPublicKey, - asset: USDC, + asset, amount: amount.toString(), }) ); @@ -466,6 +538,7 @@ export const buildPaymentTransaction = async ({ hash: transaction.hash().toString("hex"), networkPassphrase, feeSplit, + assetCode, }; } catch (error) { logger.error("Error building payment transaction:", error); @@ -478,8 +551,10 @@ export const buildReversePaymentTransaction = async ({ destinationPublicKey, amount, originalTxHash, + assetCode = DEFAULT_ASSET_CODE, }) => { try { + const asset = resolveAsset(assetCode); const sourceAccount = await timedHorizonCall("loadAccount", () => server.loadAccount(sourcePublicKey) ); @@ -492,7 +567,7 @@ export const buildReversePaymentTransaction = async ({ builder.addOperation( StellarSdk.Operation.payment({ destination: destinationPublicKey, - asset: USDC, + asset, amount: amount.toString(), }) ); @@ -524,7 +599,6 @@ export const submitTransaction = async (signedXdr) => { networkPassphrase ); - // Using mode: 'submit' and passing a verifyFn to safely handle timeouts const verifyFn = async () => { const ver = await verifyTransaction(transaction.hash().toString("hex")); if (ver.exists) { @@ -547,7 +621,7 @@ export const submitTransaction = async (signedXdr) => { if (error.response?.data?.extras?.result_codes) { const codes = error.response.data.extras.result_codes; if (codes.operations?.includes("op_underfunded")) { - throw new Error("Insufficient USDC balance"); + throw new Error("Insufficient balance"); } if ( codes.operations?.some( @@ -560,7 +634,7 @@ export const submitTransaction = async (signedXdr) => { } if (codes.operations?.includes("op_no_trust")) { throw new Error( - "Recipient does not have a USDC trustline. They need to add USDC to their wallet first." + "Recipient does not have a trustline for this asset. They need to add it to their wallet first." ); } if (codes.operations?.includes("op_no_destination")) { @@ -597,9 +671,14 @@ export const verifyTransaction = async (txHash) => { } }; -export const verifyPaymentOperations = async (txHash, expectedPayments) => { +/** + * Verify that expected payments landed on-chain in the given asset. + * assetCode defaults to the registry default (USDC) for back-compat. + */ +export const verifyPaymentOperations = async (txHash, expectedPayments, assetCode = DEFAULT_ASSET_CODE) => { try { const verification = await verifyTransaction(txHash); + const assetConfig = getAssetConfig(assetCode, NETWORK); if (!verification.exists) { return { verified: false, transient: true, reason: "Transaction not found on network" }; @@ -608,16 +687,23 @@ export const verifyPaymentOperations = async (txHash, expectedPayments) => { return { verified: false, reason: "Transaction was not successful" }; } + const matchesAsset = (op, codeField, issuerField, typeField) => { + if (!assetConfig.issuer) { + return op[typeField] === "native"; + } + return op[codeField] === assetConfig.code && op[issuerField] === assetConfig.issuer; + }; + const paymentOps = verification.operations.filter((op) => { if (op.type === "payment") { - return ( - op.asset_code === "USDC" && op.asset_issuer === USDC_ISSUER - ); + return matchesAsset(op, "asset_code", "asset_issuer", "asset_type"); } if (op.type === "path_payment_strict_receive") { - return ( - op.destination_asset_code === "USDC" && - op.destination_asset_issuer === USDC_ISSUER + return matchesAsset( + op, + "destination_asset_code", + "destination_asset_issuer", + "destination_asset_type" ); } return false; @@ -636,7 +722,7 @@ export const verifyPaymentOperations = async (txHash, expectedPayments) => { if (!match) { return { verified: false, - reason: `Missing expected USDC payment of ${expected.amount} to ${expected.destination}`, + reason: `Missing expected ${assetCode} payment of ${expected.amount} to ${expected.destination}`, }; } } @@ -648,15 +734,21 @@ export const verifyPaymentOperations = async (txHash, expectedPayments) => { } }; -export const hasUsdcTrustline = async (publicKey) => { +export const hasTrustline = async (publicKey, assetCode = DEFAULT_ASSET_CODE) => { + const config = getAssetConfig(assetCode, NETWORK); + if (!config) return false; + if (!config.issuer) return true; // native XLM never needs a trustline try { const balance = await getAccountBalance(publicKey); - return balance.hasTrustline; - } catch (error) { + return !!balance.trustlines[assetCode]; + } catch { return false; } }; +// Thin back-compat wrapper: existing callers use hasUsdcTrustline directly. +export const hasUsdcTrustline = async (publicKey) => hasTrustline(publicKey, "USDC"); + export const getExplorerUrl = (txHash) => { const baseUrl = NETWORK === "mainnet" @@ -673,7 +765,6 @@ export const getAccountExplorerUrl = (publicKey) => { return baseUrl + publicKey; }; -// Export client.endpoints[0].server as a fallback for other modules not yet refactored (e.g. payoutService) export const server = client.endpoints[0].server; export { @@ -684,4 +775,5 @@ export { DONATION_WALLET_PUBLIC_KEY, PLATFORM_FEE_PERCENT, PLATFORM_WALLET_PUBLIC_KEY, -}; + DEFAULT_ASSET_CODE, +}; \ No newline at end of file diff --git a/test/refund.test.js b/test/refund.test.js index bb817e30..e9ebed90 100644 --- a/test/refund.test.js +++ b/test/refund.test.js @@ -31,6 +31,7 @@ const generateToken = (userId, role = "student") => { describe("Non-Custodial Refund & Dispute Flow (#62)", () => { let buyer, educator, otherUser, adminUser; + let buyerWallet; let buyerToken, educatorToken, otherToken, adminToken; let confirmedTx; let course; @@ -68,7 +69,15 @@ describe("Non-Custodial Refund & Dispute Flow (#62)", () => { jest.spyOn(server, "operations").mockImplementation(() => ({ forTransaction: () => ({ call: async () => ({ - records: [], + records: [ + { + type: "payment", + to: buyerWallet, + amount: "50", + asset_code: "USDC", + asset_issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + }, + ], }), }), })); @@ -88,7 +97,7 @@ describe("Non-Custodial Refund & Dispute Flow (#62)", () => { await Transaction.deleteMany({}); await Refund.deleteMany({}); - const buyerWallet = StellarSdk.Keypair.random().publicKey(); + buyerWallet = StellarSdk.Keypair.random().publicKey(); const educatorWallet = StellarSdk.Keypair.random().publicKey(); const otherWallet = StellarSdk.Keypair.random().publicKey(); @@ -140,7 +149,7 @@ describe("Non-Custodial Refund & Dispute Flow (#62)", () => { enrolledUsers: [buyer._id], }); - buyer.purchasedCourses = [course._id]; + buyer.purchasedCourses = [{ courseId: course._id, purchaseDate: new Date() }]; await buyer.save(); // Create confirmed purchase transaction @@ -291,7 +300,7 @@ describe("Non-Custodial Refund & Dispute Flow (#62)", () => { const updatedCourse = await Course.findById(course._id); const updatedTx = await Transaction.findById(confirmedTx._id); - expect(updatedBuyer.purchasedCourses.map((c) => c.toString())).not.toContain(course._id.toString()); + expect(updatedBuyer.purchasedCourses.map((c) => c.courseId.toString())).not.toContain(course._id.toString()); expect(updatedCourse.enrolledUsers.map((u) => u.toString())).not.toContain(buyer._id.toString()); expect(updatedCourse.enrolledUsers.length).toBe(0); expect(updatedTx.status).toBe("refunded"); diff --git a/test/stellarPaymentController.test.js b/test/stellarPaymentController.test.js index 339642c7..cee52db8 100644 --- a/test/stellarPaymentController.test.js +++ b/test/stellarPaymentController.test.js @@ -320,9 +320,7 @@ describe("Stellar payment controller", () => { expect(res.statusCode).toBe(200); expect(res.body.success).toBe(true); - expect(verifyPaymentOperations).toHaveBeenCalledWith("hash-confirmed", [ - { destination: creatorWallet, amount: "25" }, - ]); + expect(verifyPaymentOperations).toHaveBeenCalledWith("hash-confirmed", [{ destination: creatorWallet, amount: "25" }], "USDC"); expect(recordSaleEarnings).toHaveBeenCalledWith(tx, { session }); expect(buyer.purchasedCourses).toHaveLength(1); expect(buyer.purchasedCourses[0].courseId).toBe(itemId); diff --git a/test/stellarService.test.js b/test/stellarService.test.js index 1966548e..c8008999 100644 --- a/test/stellarService.test.js +++ b/test/stellarService.test.js @@ -82,10 +82,10 @@ describe("Stellar service payment flow", () => { }); it.each([ - ["op_underfunded", "Insufficient USDC balance"], + ["op_underfunded", "Insufficient balance"], [ "op_no_trust", - "Recipient does not have a USDC trustline. They need to add USDC to their wallet first.", + "Recipient does not have a trustline for this asset. They need to add it to their wallet first.", ], ["op_no_destination", "Destination account does not exist"], ])("maps %s to a clear submit error", async (operationCode, message) => { @@ -191,12 +191,7 @@ describe("Stellar service payment flow", () => { ], }); - await expect(getAccountBalance("GACCOUNT")).resolves.toEqual({ - exists: true, - xlmBalance: "3.25", - usdcBalance: "44.5", - hasTrustline: true, - }); + await expect(getAccountBalance("GACCOUNT")).resolves.toEqual({ exists: true, xlmBalance: "3.25", usdcBalance: "44.5", hasTrustline: true, balances: { USDC: "44.5", EURC: "0" }, trustlines: { USDC: true, EURC: false } }); await expect(hasUsdcTrustline("GACCOUNT")).resolves.toBe(true); }); @@ -205,12 +200,7 @@ describe("Stellar service payment flow", () => { notFound.response = { status: 404 }; jest.spyOn(server, "loadAccount").mockRejectedValue(notFound); - await expect(getAccountBalance("GMISSING")).resolves.toEqual({ - exists: false, - xlmBalance: "0", - usdcBalance: "0", - hasTrustline: false, - }); + await expect(getAccountBalance("GMISSING")).resolves.toEqual({ exists: false, xlmBalance: "0", usdcBalance: "0", hasTrustline: false, balances: { USDC: "0", EURC: "0" }, trustlines: { USDC: false, EURC: false } }); await expect(hasUsdcTrustline("GMISSING")).resolves.toBe(false); });