diff --git a/jest.config.js b/jest.config.js index 5114fe6..6a73f8b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,5 @@ export default { testEnvironment: "node", transform: {}, + setupFiles: ["/test/jest.setup.js"], }; diff --git a/src/config/validateEnv.js b/src/config/validateEnv.js index 350309e..63c5a37 100644 --- a/src/config/validateEnv.js +++ b/src/config/validateEnv.js @@ -31,6 +31,15 @@ export const validateEnv = () => { process.env.ACCESS_TOKEN_TTL = process.env.ACCESS_TOKEN_TTL || "15m"; process.env.REFRESH_TOKEN_TTL = process.env.REFRESH_TOKEN_TTL || "30d"; + // During tests, relax strict env validation and provide sane defaults + if (process.env.NODE_ENV === "test") { + process.env.JWT_SECRET = process.env.JWT_SECRET || "test-secret"; + process.env.MONGO_URI = process.env.MONGO_URI || "mongodb://127.0.0.1:27017/deenbridge_test"; + process.env.PORT = process.env.PORT || "3000"; + logger.info("✅ Skipping strict env validation in test mode"); + return; + } + const missing = []; requiredEnvVars.forEach((envVar) => { diff --git a/src/controllers/stellar/donationController.js b/src/controllers/stellar/donationController.js index b16056b..751e00c 100644 --- a/src/controllers/stellar/donationController.js +++ b/src/controllers/stellar/donationController.js @@ -92,7 +92,8 @@ export const initializeDonation = async (req, res) => { amount: amount.toString(), network: NETWORK, status: "pending", - stellarTxHash: paymentTx.hash, // Temporary hash, will be replaced with actual + expectedHash: paymentTx.hash, + memo: DONATION_MEMO, }); await donation.save({ session }); @@ -158,7 +159,33 @@ export const submitDonation = async (req, res) => { }); } - // Update status to submitted + // Build expected payment and validate signed XDR BEFORE submit + const expectedPayments = [ + { + destination: donation.creatorWallet, + amount: donation.amount, + }, + ]; + + try { + validateSignedPaymentXdr(signedXdr, expectedPayments, donation.memo, donation.buyerWallet, true); + } catch (validationError) { + donation.status = "failed"; + donation.failureReason = `validation_failed: ${validationError.message}`; + await donation.save({ session }); + await session.commitTransaction(); + paymentsFailed.inc({ type: "donation", reason: "validation_failed" }); + + logger.error(`Donation ${donationId} validation failed:`, validationError.message); + + return res.status(400).json({ + success: false, + message: "Signed transaction does not match expected donation details", + error: validationError.message, + }); + } + + // Update status to submitted after validation donation.status = "submitted"; donation.submittedAt = new Date(); await donation.save({ session }); diff --git a/src/controllers/stellar/paymentController.js b/src/controllers/stellar/paymentController.js index 97c61e6..dd5722b 100644 --- a/src/controllers/stellar/paymentController.js +++ b/src/controllers/stellar/paymentController.js @@ -10,6 +10,7 @@ import { submitTransaction, verifyTransaction, verifyPaymentOperations, + validateSignedPaymentXdr, NETWORK, getExplorerUrl, PLATFORM_WALLET_PUBLIC_KEY, @@ -188,7 +189,8 @@ export const initializePayment = async (req, res) => { network: NETWORK, status: "pending", settlement: settlementMode, - stellarTxHash: paymentTx.hash, // Temporary hash, will be replaced with actual + expectedHash: paymentTx.hash, + memo, ...(feeSplit && { platformFee: { feePercent: feeSplit.feePercent, @@ -275,7 +277,51 @@ export const submitPayment = async (req, res) => { }); } - // Update status to submitted + // Build expected payments to validate XDR BEFORE submit + let expectedPayments = transaction.platformFee?.platformAmount + ? [ + { + destination: transaction.creatorWallet, + amount: transaction.platformFee.creatorAmount, + }, + { + destination: transaction.platformFee.platformWallet, + amount: transaction.platformFee.platformAmount, + }, + ] + : [ + { + destination: transaction.creatorWallet, + amount: transaction.amount, + }, + ]; + + // Validate signed XDR contents (memo, payments, optional source) + try { + validateSignedPaymentXdr( + signedXdr, + expectedPayments, + transaction.memo, + transaction.buyerWallet, + true + ); + } catch (validationError) { + transaction.status = "failed"; + transaction.failureReason = `validation_failed: ${validationError.message}`; + await transaction.save({ session }); + await session.commitTransaction(); + paymentsFailed.inc({ type: "purchase", reason: "validation_failed" }); + + logger.error(`Transaction ${transactionId} validation failed:`, validationError.message); + + return res.status(400).json({ + success: false, + message: "Signed transaction does not match expected payment details", + error: validationError.message, + }); + } + + // Update status to submitted after validation transaction.status = "submitted"; transaction.submittedAt = new Date(); await transaction.save({ session }); @@ -304,7 +350,7 @@ 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 + expectedPayments = transaction.platformFee?.platformAmount ? [ { destination: transaction.creatorWallet, diff --git a/src/controllers/stellar/walletController.js b/src/controllers/stellar/walletController.js index 2767a23..e7d2ca3 100644 --- a/src/controllers/stellar/walletController.js +++ b/src/controllers/stellar/walletController.js @@ -1,4 +1,5 @@ // controllers/stellar/walletController.js +import mongoose from "mongoose"; import User from "../../models/User.js"; import { isValidPublicKey, @@ -121,8 +122,11 @@ export const getWalletBalance = async (req, res) => { res.status(200).json({ success: true, - publicKey, - ...balance, + message: "Wallet balance fetched successfully", + data: { + publicKey, + ...balance, + }, }); } catch (error) { logger.error("Get wallet balance error:", error); @@ -176,9 +180,21 @@ export const checkUserWallet = async (req, res) => { try { const { userId } = req.params; - const user = await User.findById(userId).select( - "stellarWallet.publicKey name" - ); + if (!mongoose.Types.ObjectId.isValid(userId)) { + return res.status(400).json({ + success: false, + message: "Invalid user ID", + }); + } + + if (userId !== req.user._id.toString()) { + return res.status(403).json({ + success: false, + message: "Forbidden: You can only check your own wallet status", + }); + } + + const user = await User.findById(userId).select("stellarWallet.publicKey"); if (!user) { return res.status(404).json({ @@ -189,8 +205,10 @@ export const checkUserWallet = async (req, res) => { res.status(200).json({ success: true, - hasWallet: !!user.stellarWallet?.publicKey, - userName: user.name, + message: "Wallet status fetched successfully", + data: { + hasWallet: !!user.stellarWallet?.publicKey, + }, }); } catch (error) { logger.error("Check user wallet error:", error); diff --git a/src/models/Transaction.js b/src/models/Transaction.js index 4652c75..e13d9b6 100644 --- a/src/models/Transaction.js +++ b/src/models/Transaction.js @@ -4,12 +4,6 @@ import mongoose from "mongoose"; const transactionSchema = new mongoose.Schema( { // Transaction identification - stellarTxHash: { - type: String, - required: true, - unique: true, - index: true, - }, stellarLedger: { type: Number, }, @@ -46,6 +40,14 @@ const transactionSchema = new mongoose.Schema( required: true, }, + // Transaction hash from the Stellar network, only set after confirmation + stellarTxHash: { + type: String, + unique: true, + sparse: true, + index: true, + }, + // Item being purchased (not applicable to donations) itemType: { type: String, @@ -90,6 +92,13 @@ const transactionSchema = new mongoose.Schema( enum: ["testnet", "mainnet"], required: true, }, + expectedHash: { + type: String, + index: true, + }, + memo: { + type: String, + }, // Platform fee split (only set when a fee was applied at build time) platformFee: { diff --git a/src/routes/stellar/walletRoutes.js b/src/routes/stellar/walletRoutes.js index 7a8e689..4e43d51 100644 --- a/src/routes/stellar/walletRoutes.js +++ b/src/routes/stellar/walletRoutes.js @@ -16,8 +16,8 @@ router.post("/connect", protect, connectWallet); router.delete("/disconnect", protect, disconnectWallet); router.get("/me", protect, getMyWallet); -// Public routes -router.get("/balance/:publicKey", getWalletBalance); -router.get("/check/:userId", checkUserWallet); +// Lookup routes require authentication to avoid wallet/user enumeration. +router.get("/balance/:publicKey", protect, getWalletBalance); +router.get("/check/:userId", protect, checkUserWallet); export default router; diff --git a/src/services/stellar/stellarService.js b/src/services/stellar/stellarService.js index 8daa79c..9683ae9 100644 --- a/src/services/stellar/stellarService.js +++ b/src/services/stellar/stellarService.js @@ -260,6 +260,71 @@ export const submitTransaction = async (signedXdr) => { } }; +/** + * Validate a signed transaction XDR against expected payments and memo/source + * @param {string} signedXdr + * @param {Array<{destination:string, amount:string}>} expectedPayments + * @param {string} expectedMemo + * @param {string} expectedSource + * @param {boolean} requireSource + */ +export const validateSignedPaymentXdr = ( + signedXdr, + expectedPayments = [], + expectedMemo, + expectedSource, + requireSource = true +) => { + const tx = StellarSdk.TransactionBuilder.fromXDR( + signedXdr, + networkPassphrase + ); + + // Memo check + if (expectedMemo) { + const memo = tx.memo; + let memoText = null; + if (memo && memo._type === "text") { + const val = memo._value; + memoText = Buffer.isBuffer(val) ? val.toString() : String(val); + } + if (memoText !== expectedMemo) { + throw new Error("Memo mismatch"); + } + } + + // Source check + if (requireSource && expectedSource) { + if (tx.source !== expectedSource) { + throw new Error("Source account mismatch"); + } + } + + // Payment operations check + const paymentOps = tx.operations.filter((op) => op.type === "payment"); + + for (const expected of expectedPayments) { + const match = paymentOps.find((op) => { + const assetMatches = + (op.asset && op.asset.code === "USDC" && op.asset.issuer === USDC_ISSUER) || + (op.asset_type === "credit_alphanum4" && op.asset?.code === "USDC" && op.asset?.issuer === USDC_ISSUER); + + const amountMatches = toStroops(op.amount) === toStroops(expected.amount); + const destMatches = op.destination === expected.destination; + + return assetMatches && amountMatches && destMatches; + }); + + if (!match) { + throw new Error( + `Signed XDR missing expected USDC payment of ${expected.amount} to ${expected.destination}` + ); + } + } + + return tx; +}; + export const verifyTransaction = async (txHash) => { try { const tx = await timedHorizonCall("fetchTransaction", () => diff --git a/test/jest.setup.js b/test/jest.setup.js new file mode 100644 index 0000000..cf6d7dd --- /dev/null +++ b/test/jest.setup.js @@ -0,0 +1,8 @@ +import { MongoMemoryServer } from 'mongodb-memory-server'; + +process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'; + +// Start an in-memory MongoDB for tests +const mongod = await MongoMemoryServer.create(); +process.env.MONGO_URI = mongod.getUri(); +global.__MONGOD__ = mongod; diff --git a/test/stellarService.validation.test.js b/test/stellarService.validation.test.js new file mode 100644 index 0000000..38ec86e --- /dev/null +++ b/test/stellarService.validation.test.js @@ -0,0 +1,85 @@ +import { jest } from "@jest/globals"; +import * as StellarSdk from "@stellar/stellar-sdk"; +import { buildPaymentTransaction, validateSignedPaymentXdr, server, networkPassphrase, USDC_ISSUER } from "../src/services/stellar/stellarService.js"; + +describe("Stellar signed XDR validation", () => { + beforeEach(() => { + // Mock Horizon loadAccount to return an account with balances + jest.spyOn(server, "loadAccount").mockImplementation(async (key) => { + const acc = new StellarSdk.Account(key || StellarSdk.Keypair.random().publicKey(), "1"); + acc.balances = [ + { asset_type: "native", balance: "10" }, + { asset_code: "USDC", asset_issuer: USDC_ISSUER, balance: "100" }, + ]; + return acc; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("validates a correctly-formed signed XDR", async () => { + const sourceKeypair = StellarSdk.Keypair.random(); + const destKeypair = StellarSdk.Keypair.random(); + const memo = "DNB-TEST-MEMO"; + + const payment = await buildPaymentTransaction({ + sourcePublicKey: sourceKeypair.publicKey(), + destinationPublicKey: destKeypair.publicKey(), + amount: "5", + memo, + }); + + const tx = StellarSdk.TransactionBuilder.fromXDR(payment.xdr, networkPassphrase); + tx.sign(sourceKeypair); + const signedXdr = tx.toXDR(); + + // Expect no throw + expect(() => + validateSignedPaymentXdr( + signedXdr, + [{ destination: destKeypair.publicKey(), amount: "5" }], + memo, + sourceKeypair.publicKey(), + true + ) + ).not.toThrow(); + }); + + it("rejects a tampered XDR with wrong amount or destination", async () => { + const sourceKeypair = StellarSdk.Keypair.random(); + const destKeypair = StellarSdk.Keypair.random(); + const otherDest = StellarSdk.Keypair.random(); + const memo = "DNB-TEST-MEMO"; + + const payment = await buildPaymentTransaction({ + sourcePublicKey: sourceKeypair.publicKey(), + destinationPublicKey: destKeypair.publicKey(), + amount: "5", + memo, + }); + + // Create a tampered XDR by building a different transaction (amount 1) + const tampered = await buildPaymentTransaction({ + sourcePublicKey: sourceKeypair.publicKey(), + destinationPublicKey: otherDest.publicKey(), + amount: "1", + memo, + }); + + const txTampered = StellarSdk.TransactionBuilder.fromXDR(tampered.xdr, networkPassphrase); + txTampered.sign(sourceKeypair); + const tamperedXdr = txTampered.toXDR(); + + expect(() => + validateSignedPaymentXdr( + tamperedXdr, + [{ destination: destKeypair.publicKey(), amount: "5" }], + memo, + sourceKeypair.publicKey(), + true + ) + ).toThrow(); + }); +}); diff --git a/test/stellarWalletRoutes.test.js b/test/stellarWalletRoutes.test.js new file mode 100644 index 0000000..963e139 --- /dev/null +++ b/test/stellarWalletRoutes.test.js @@ -0,0 +1,206 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import request from "supertest"; +import jwt from "jsonwebtoken"; +import mongoose from "mongoose"; + +const getAccountBalance = jest.fn(); +const isValidPublicKey = jest.fn(); + +const authUserId = new mongoose.Types.ObjectId().toString(); +const targetUserId = new mongoose.Types.ObjectId().toString(); + +const User = { + findById: jest.fn(), + findOne: jest.fn(), + findByIdAndUpdate: jest.fn(), +}; + +jest.unstable_mockModule("../src/models/User.js", () => ({ + default: User, +})); + +jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({ + isValidPublicKey, + getAccountBalance, + NETWORK: "testnet", +})); + +const walletRoutes = (await import("../src/routes/stellar/walletRoutes.js")) + .default; + +const makeQuery = (result) => ({ + select: jest.fn(() => Promise.resolve(result)), +}); + +const buildApp = () => { + const app = express(); + app.use(express.json()); + app.use("/wallet", walletRoutes); + return app; +}; + +const authHeader = () => { + const token = jwt.sign({ userId: authUserId }, process.env.JWT_SECRET); + return `Bearer ${token}`; +}; + +describe("Stellar wallet lookup route protection", () => { + beforeEach(() => { + jest.clearAllMocks(); + User.findById.mockImplementation((id) => { + if (id.toString() === authUserId) { + return makeQuery({ _id: authUserId, name: "Authenticated User" }); + } + if (id.toString() === targetUserId) { + return makeQuery({ + _id: targetUserId, + name: "Hidden Name", + stellarWallet: { + publicKey: + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + }, + }); + } + return makeQuery(null); + }); + }); + + it("requires authentication before checking another user's wallet status", async () => { + const res = await request(buildApp()).get(`/wallet/check/${targetUserId}`); + + expect(res.statusCode).toBe(401); + expect(res.body).toMatchObject({ + success: false, + message: "No token, authorization denied", + }); + expect(User.findById).not.toHaveBeenCalled(); + }); + + it("returns 400 for invalid user IDs without querying the target user", async () => { + const res = await request(buildApp()) + .get("/wallet/check/not-a-valid-object-id") + .set("Authorization", authHeader()); + + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ + success: false, + message: "Invalid user ID", + }); + expect(User.findById).toHaveBeenCalledTimes(1); + expect(User.findById).toHaveBeenCalledWith(authUserId); + }); + + it("returns 403 when an authenticated user checks another user's wallet status", async () => { + const res = await request(buildApp()) + .get(`/wallet/check/${targetUserId}`) + .set("Authorization", authHeader()); + + expect(res.statusCode).toBe(403); + expect(res.body).toEqual({ + success: false, + message: "Forbidden: You can only check your own wallet status", + }); + expect(User.findById).toHaveBeenCalledTimes(1); + expect(User.findById).toHaveBeenCalledWith(authUserId); + }); + + it("returns 404 when the authenticated user's wallet record disappears", async () => { + User.findById + .mockReturnValueOnce(makeQuery({ _id: authUserId, name: "Authenticated User" })) + .mockReturnValueOnce(makeQuery(null)); + + const res = await request(buildApp()) + .get(`/wallet/check/${authUserId}`) + .set("Authorization", authHeader()); + + expect(res.statusCode).toBe(404); + expect(res.body).toEqual({ + success: false, + message: "User not found", + }); + }); + + it("returns only wallet status for authenticated self-lookup requests", async () => { + User.findById + .mockReturnValueOnce(makeQuery({ _id: authUserId, name: "Authenticated User" })) + .mockReturnValueOnce( + makeQuery({ + _id: authUserId, + name: "Hidden Name", + stellarWallet: { + publicKey: + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + }, + }) + ); + + const res = await request(buildApp()) + .get(`/wallet/check/${authUserId}`) + .set("Authorization", authHeader()); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ + success: true, + message: "Wallet status fetched successfully", + data: { + hasWallet: true, + }, + }); + expect(res.body.userName).toBeUndefined(); + }); + + it("requires authentication before proxying wallet balance lookups", async () => { + const publicKey = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + + const res = await request(buildApp()).get(`/wallet/balance/${publicKey}`); + + expect(res.statusCode).toBe(401); + expect(isValidPublicKey).not.toHaveBeenCalled(); + expect(getAccountBalance).not.toHaveBeenCalled(); + }); + + it("validates public keys before calling Horizon", async () => { + isValidPublicKey.mockReturnValue(false); + + const res = await request(buildApp()) + .get("/wallet/balance/not-a-stellar-key") + .set("Authorization", authHeader()); + + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ + success: false, + message: "Invalid public key", + }); + expect(getAccountBalance).not.toHaveBeenCalled(); + }); + + it("allows authenticated balance lookups for valid public keys", async () => { + const publicKey = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + isValidPublicKey.mockReturnValue(true); + getAccountBalance.mockResolvedValue({ + exists: true, + xlmBalance: "3", + usdcBalance: "10", + hasTrustline: true, + }); + + const res = await request(buildApp()) + .get(`/wallet/balance/${publicKey}`) + .set("Authorization", authHeader()); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ + success: true, + message: "Wallet balance fetched successfully", + data: { + publicKey, + exists: true, + xlmBalance: "3", + usdcBalance: "10", + hasTrustline: true, + }, + }); + expect(getAccountBalance).toHaveBeenCalledWith(publicKey); + }); +});