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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ DONATION_WALLET_PUBLIC_KEY=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
PLATFORM_FEE_PERCENT=0
PLATFORM_WALLET_PUBLIC_KEY=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

# Optional fee-bump sponsorship (disabled by default)
FEE_SPONSOR_ENABLED=false
FEE_SPONSOR_SECRET=SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
FEE_SPONSOR_MAX_FEE_STROOPS=1000000
FEE_SPONSOR_DAILY_CAP_STROOPS=10000000
FEE_SPONSOR_PER_USER_DAILY_LIMIT=5

# Token TTLs
ACCESS_TOKEN_TTL=15m
REFRESH_TOKEN_TTL=30d
Expand Down
15 changes: 15 additions & 0 deletions src/config/validateEnv.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ const optionalEnvVars = [
"JOBS_ENABLED",
"JOBS_DASHBOARD_TOKEN",
"EMAILJS_RECEIPT_TEMPLATE_ID",
"FEE_SPONSOR_SECRET",
"FEE_SPONSOR_ENABLED",
"FEE_SPONSOR_MAX_FEE_STROOPS",
"FEE_SPONSOR_DAILY_CAP_STROOPS",
"FEE_SPONSOR_PER_USER_DAILY_LIMIT",
];

export const validateEnv = () => {
Expand All @@ -53,6 +58,16 @@ export const validateEnv = () => {
process.exit(1);
}

if (
process.env.FEE_SPONSOR_ENABLED === "true" &&
!process.env.FEE_SPONSOR_SECRET
) {
logger.error(
"FEE_SPONSOR_ENABLED is 'true' but FEE_SPONSOR_SECRET is not set."
);
process.exit(1);
}

// Check JWT_SECRET strength
if (process.env.JWT_SECRET && process.env.JWT_SECRET.length < 32) {
logger.warn(
Expand Down
130 changes: 126 additions & 4 deletions src/controllers/stellar/donationController.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import {
DONATION_WALLET_PUBLIC_KEY,
} from "../../services/stellar/stellarService.js";
import logger from "../../config/logger.js";
import {
FeeSponsorshipError,
prepareSponsoredTransaction,
reconcileSponsoredTransaction,
submitPreparedSponsoredTransaction,
} from "../../services/stellar/feeSponsorService.js";
import { enqueue } from "../../jobs/queue.js";
import {
paymentsInitialized,
Expand Down Expand Up @@ -132,7 +138,7 @@ export const submitDonation = async (req, res) => {
session.startTransaction();

try {
const { donationId, signedXdr } = req.body;
const { donationId, signedXdr, requestSponsorship = false } = req.body;
const donorId = req.user._id;

if (!donationId || !signedXdr) {
Expand All @@ -142,9 +148,17 @@ export const submitDonation = async (req, res) => {
message: "Donation ID and signed XDR are required",
});
}
if (typeof requestSponsorship !== "boolean") {
await session.abortTransaction();
return res.status(400).json({
success: false,
message: "requestSponsorship must be a boolean",
data: null,
});
}

// Find the pending donation
const donation = await Transaction.findOne({
let donation = await Transaction.findOne({
_id: donationId,
buyer: donorId,
type: "donation",
Expand All @@ -159,16 +173,116 @@ export const submitDonation = async (req, res) => {
});
}

let result;
if (requestSponsorship === true) {
if (
donation.sponsorshipAttemptKey &&
["prepared", "unknown"].includes(donation.sponsorshipStatus)
) {
result = await reconcileSponsoredTransaction(
donation.sponsorshipAttemptKey
);
if (!result) {
await session.abortTransaction();
return res.status(409).json({
success: false,
message: "The previous sponsorship outcome is still being reconciled",
data: {
code: "sponsorship_outcome_unknown",
canSubmitNormally: false,
},
});
}
} else {
let prepared;
try {
prepared = await prepareSponsoredTransaction(signedXdr, donation);
donation.sponsorshipAttemptKey = prepared.innerHash;
donation.sponsorshipStatus = "prepared";
donation.sponsorshipAttemptedAt = new Date();
await donation.save({ session });
await session.commitTransaction();
result = await submitPreparedSponsoredTransaction(
prepared,
donation,
donorId
);
} catch (error) {
const safeToRetry =
error instanceof FeeSponsorshipError &&
error.code !== "sponsored_submission_failed";
if (safeToRetry) {
if (session.inTransaction()) await session.abortTransaction();
await Transaction.updateOne(
{ _id: donation._id },
{
$set: {
sponsorshipStatus: "rejected",
sponsorshipFailureCode: error.code,
},
}
);
return res.status(error.status).json({
success: false,
message: error.message,
data: { code: error.code, canSubmitNormally: true },
});
}
result = prepared
? await reconcileSponsoredTransaction(prepared.innerHash)
: null;
if (!result) {
if (session.inTransaction()) await session.abortTransaction();
await Transaction.updateOne(
{ _id: donation._id },
{ $set: { sponsorshipStatus: "unknown" } }
);
return res.status(409).json({
success: false,
message: "Sponsored submission outcome is unknown; do not resubmit normally",
data: {
code: "sponsorship_outcome_unknown",
canSubmitNormally: false,
},
});
}
}

session.startTransaction();
donation = await Transaction.findOne({
_id: donationId,
buyer: donorId,
type: "donation",
status: "pending",
}).session(session);
if (!donation) {
await session.abortTransaction();
return res.status(409).json({
success: false,
message: "Donation state changed while sponsorship was submitted",
data: { code: "donation_state_changed", canSubmitNormally: false },
});
}
}

if (!session.inTransaction()) session.startTransaction();
if (!donation.$session()) donation.$session(session);
if (result.reconciled && donation.sponsorshipStatus !== "submitted") {
logger.info("Recovered sponsored donation by inner transaction hash", {
transactionId: donation._id.toString(),
});
}
}

// Update status to submitted
donation.status = "submitted";
donation.submittedAt = new Date();
await donation.save({ session });
paymentsSubmitted.inc({ type: "donation" });

// Submit to Stellar network
let result;
try {
result = await submitTransaction(signedXdr);
result = result || (await submitTransaction(signedXdr));
} catch (stellarError) {
donation.status = "failed";
donation.failureReason = stellarError.message;
Expand All @@ -185,6 +299,14 @@ export const submitDonation = async (req, res) => {
});
}

if (requestSponsorship === true) {
donation.sponsored = true;
donation.sponsorFeeStroops = result.feeCharged.toString();
donation.innerTxHash = result.innerHash;
donation.feeBumpTxHash = result.hash;
donation.sponsorshipStatus = "submitted";
}

// Verify on-chain that the donation actually paid the fund (amount, destination, asset)
const verification = await verifyPaymentOperations(result.hash, [
{
Expand Down
Loading
Loading