diff --git a/jest.config.js b/jest.config.js index 5114fe6..8a23dfe 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,5 @@ export default { testEnvironment: "node", transform: {}, + setupFilesAfterEnv: ["./test/jest.setup.js"], }; diff --git a/src/config/validateEnv.js b/src/config/validateEnv.js index 1c583be..595cc97 100644 --- a/src/config/validateEnv.js +++ b/src/config/validateEnv.js @@ -31,6 +31,13 @@ const optionalEnvVars = [ ]; export const validateEnv = () => { + // Test mode: provide defaults for development of tests + if (process.env.NODE_ENV === "test") { + process.env.JWT_SECRET = process.env.JWT_SECRET || "test-secret-key-at-least-32-characters-long"; + process.env.MONGO_URI = process.env.MONGO_URI || "mongodb://test-db:27017/dnb-test"; + process.env.PORT = process.env.PORT || "5000"; + } + // Default values for TTLs if not provided process.env.ACCESS_TOKEN_TTL = process.env.ACCESS_TOKEN_TTL || "15m"; process.env.REFRESH_TOKEN_TTL = process.env.REFRESH_TOKEN_TTL || "30d"; diff --git a/src/controllers/stellar/donationController.js b/src/controllers/stellar/donationController.js index 50b05be..bfd81b5 100644 --- a/src/controllers/stellar/donationController.js +++ b/src/controllers/stellar/donationController.js @@ -8,6 +8,7 @@ import { buildSep7Uri, submitTransaction, verifyPaymentOperations, + validateSignedPaymentXdr, getExplorerUrl, NETWORK, DONATION_WALLET_PUBLIC_KEY, @@ -93,7 +94,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 }); @@ -159,7 +161,40 @@ export const submitDonation = async (req, res) => { }); } - // Update status to submitted + // Build expected payments array for validation + const expectedPayments = [ + { + destination: donation.creatorWallet, + amount: donation.amount, + }, + ]; + + // Validate signed XDR contents (memo, payments, optional source) + 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 payment details", + error: validationError.message, + }); + } + + // Update status to submitted after validation donation.status = "submitted"; donation.submittedAt = new Date(); await donation.save({ session }); @@ -186,12 +221,8 @@ export const submitDonation = async (req, res) => { } // Verify on-chain that the donation actually paid the fund (amount, destination, asset) - const verification = await verifyPaymentOperations(result.hash, [ - { - destination: donation.creatorWallet, - amount: donation.amount, - }, - ]); + // (expectedPayments already defined above for pre-submission validation) + const verification = await verifyPaymentOperations(result.hash, expectedPayments); if (!verification.verified) { donation.stellarTxHash = result.hash; diff --git a/src/controllers/stellar/paymentController.js b/src/controllers/stellar/paymentController.js index 28272a4..8add5fb 100644 --- a/src/controllers/stellar/paymentController.js +++ b/src/controllers/stellar/paymentController.js @@ -11,6 +11,7 @@ import { submitTransaction, verifyTransaction, verifyPaymentOperations, + validateSignedPaymentXdr, findPaymentPaths, applySlippage, NETWORK, @@ -333,7 +334,8 @@ export const initializePayment = async (req, res) => { network: NETWORK, status: "pending", settlement: settlementMode, - stellarTxHash: paymentTx.hash, + expectedHash: paymentTx.hash, + memo, ...(sendAssetInput && { sendAsset: sendAssetInput, sendMax, @@ -428,7 +430,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 }); @@ -457,23 +503,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 - ? [ - { - destination: transaction.creatorWallet, - amount: transaction.platformFee.creatorAmount, - }, - { - destination: transaction.platformFee.platformWallet, - amount: transaction.platformFee.platformAmount, - }, - ] - : [ - { - destination: transaction.creatorWallet, - amount: transaction.amount, - }, - ]; + // (expectedPayments already defined above for pre-submission validation) const verification = await verifyPaymentOperations( result.hash, diff --git a/src/models/Transaction.js b/src/models/Transaction.js index 9d460fc..2c59a1b 100644 --- a/src/models/Transaction.js +++ b/src/models/Transaction.js @@ -6,10 +6,17 @@ const transactionSchema = new mongoose.Schema( // Transaction identification stellarTxHash: { type: String, - required: true, + sparse: true, unique: true, index: true, }, + expectedHash: { + type: String, + index: true, + }, + memo: { + type: String, + }, stellarLedger: { type: Number, }, diff --git a/src/services/stellar/stellarService.js b/src/services/stellar/stellarService.js index e697768..593f5a4 100644 --- a/src/services/stellar/stellarService.js +++ b/src/services/stellar/stellarService.js @@ -386,6 +386,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..6ad10cc --- /dev/null +++ b/test/jest.setup.js @@ -0,0 +1,22 @@ +import { MongoMemoryServer } from "mongodb-memory-server"; + +let mongod; + +beforeAll(async () => { + // Start in-memory MongoDB + mongod = await MongoMemoryServer.create(); + const mongoUri = mongod.getUri(); + + // Set test environment and MongoDB URI + process.env.NODE_ENV = "test"; + process.env.MONGO_URI = mongoUri; + process.env.JWT_SECRET = process.env.JWT_SECRET || "test-secret-key-at-least-32-characters-long"; + process.env.PORT = process.env.PORT || "5000"; +}); + +afterAll(async () => { + if (mongod) { + await mongod.stop(); + } +}); +