Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export default {
testEnvironment: "node",
transform: {},
setupFilesAfterEnv: ["./test/jest.setup.js"],
};
7 changes: 7 additions & 0 deletions src/config/validateEnv.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Comment on lines +34 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Centralize test configuration before application imports.

The two files duplicate source-controlled test defaults, and validateEnv() can run before beforeAll publishes the in-memory URI. Remove hardcoded credentials/configuration and establish the test environment before importing application modules.

  • src/config/validateEnv.js#L34-L38: remove the JWT, Mongo URI, and port literals; require values from test bootstrap/environment.
  • test/jest.setup.js#L11-L14: retain the generated MongoMemoryServer URI, but move static defaults to environment bootstrap that runs before app imports.
📍 Affects 2 files
  • src/config/validateEnv.js#L34-L38 (this comment)
  • test/jest.setup.js#L11-L14
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/config/validateEnv.js` around lines 34 - 38, Centralize test environment
setup before application imports: in src/config/validateEnv.js, remove the
test-mode assignments for JWT_SECRET, MONGO_URI, and PORT so validateEnv() only
consumes preconfigured values; in test/jest.setup.js, preserve the generated
MongoMemoryServer URI while moving the static JWT secret and port defaults into
the earliest environment bootstrap path.

Source: Path instructions

}

// 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";
Expand Down
47 changes: 39 additions & 8 deletions src/controllers/stellar/donationController.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
buildSep7Uri,
submitTransaction,
verifyPaymentOperations,
validateSignedPaymentXdr,
getExplorerUrl,
NETWORK,
DONATION_WALLET_PUBLIC_KEY,
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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 });
Expand All @@ -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;
Expand Down
68 changes: 49 additions & 19 deletions src/controllers/stellar/paymentController.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
submitTransaction,
verifyTransaction,
verifyPaymentOperations,
validateSignedPaymentXdr,
findPaymentPaths,
applySlippage,
NETWORK,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Comment on lines +433 to +477

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major

Path payments will always fail this pre-submission check.

expectedPayments here doesn't account for path payments (see isPathPayment branch at Lines 278-304/295-304, which builds a path_payment_strict_receive operation via buildPathPaymentTransaction). validateSignedPaymentXdr in stellarService.js only matches on op.type === "payment", so a signed path-payment XDR will never find a match and every legitimate path payment will be rejected with 400 and marked failed. Root cause and fix belong in stellarService.js's validateSignedPaymentXdr — see that file's review for details.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/stellar/paymentController.js` around lines 433 - 477, Update
validateSignedPaymentXdr in stellarService.js to recognize and validate
path_payment_strict_receive operations in addition to regular payment
operations. Match path payments against the expected destination and amount
using the operation’s path-payment fields, while preserving existing validation
behavior for standard payments so legitimate path-payment XDRs pass
pre-submission validation.

transaction.status = "submitted";
transaction.submittedAt = new Date();
await transaction.save({ session });
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion src/models/Transaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
65 changes: 65 additions & 0 deletions src/services/stellar/stellarService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Comment on lines +389 to +453

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant file and nearby symbols first.
ast-grep outline src/services/stellar/stellarService.js --view expanded || true

# Show the target region with line numbers.
sed -n '340,560p' src/services/stellar/stellarService.js | cat -n

# Find the path-payment builder / initializer references.
rg -n "buildPathPaymentTransaction|isPathPayment|validateSignedPaymentXdr|verifyPaymentOperations|path_payment_strict_receive|pathPaymentStrictReceive|toStroops" src/services/stellar/stellarService.js

Repository: Deen-Bridge/dnb-backend

Length of output: 10474


validateSignedPaymentXdr should accept path payments.
paymentOps only keeps op.type === "payment", so valid pathPaymentStrictReceive XDRs fail this pre-submission check and get marked failed before the on-chain verifier runs. Mirror the verifyPaymentOperations branch here and compare the path-payment destination asset and amount.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/stellar/stellarService.js` around lines 389 - 453, Update
validateSignedPaymentXdr to include pathPaymentStrictReceive operations
alongside payment operations, mirroring the operation handling in
verifyPaymentOperations. For path payments, compare the destination asset,
destination amount, and destination address against each expected payment while
preserving the existing native/USDC payment matching behavior.

Source: Path instructions

export const verifyTransaction = async (txHash) => {
try {
const tx = await timedHorizonCall("fetchTransaction", () =>
Expand Down
22 changes: 22 additions & 0 deletions test/jest.setup.js
Original file line number Diff line number Diff line change
@@ -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();
}
});