From f9b98fdbcf789c84fc0b97ac31d33efdb243393b Mon Sep 17 00:00:00 2001 From: Dayz Tech Co Date: Wed, 22 Jul 2026 09:52:52 +0100 Subject: [PATCH 1/2] feat(stellar): add fee-bump sponsorship --- .env.example | 7 + src/config/validateEnv.js | 5 + src/controllers/stellar/donationController.js | 34 ++- src/controllers/stellar/paymentController.js | 48 +++- src/models/FeeSponsorDailySpend.js | 12 + src/models/Transaction.js | 4 + src/routes/stellar/paymentRoutes.js | 2 + src/services/stellar/feeSponsorService.js | 229 ++++++++++++++++++ src/services/stellar/stellarService.js | 1 + test/feeSponsor.test.js | 134 ++++++++++ 10 files changed, 470 insertions(+), 6 deletions(-) create mode 100644 src/models/FeeSponsorDailySpend.js create mode 100644 src/services/stellar/feeSponsorService.js create mode 100644 test/feeSponsor.test.js diff --git a/.env.example b/.env.example index 809b567c..334f132a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/src/config/validateEnv.js b/src/config/validateEnv.js index 1c583be3..8f4bcdcb 100644 --- a/src/config/validateEnv.js +++ b/src/config/validateEnv.js @@ -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 = () => { diff --git a/src/controllers/stellar/donationController.js b/src/controllers/stellar/donationController.js index 50b05be2..c3eebdda 100644 --- a/src/controllers/stellar/donationController.js +++ b/src/controllers/stellar/donationController.js @@ -13,6 +13,10 @@ import { DONATION_WALLET_PUBLIC_KEY, } from "../../services/stellar/stellarService.js"; import logger from "../../config/logger.js"; +import { + FeeSponsorshipError, + submitSponsoredTransaction, +} from "../../services/stellar/feeSponsorService.js"; import { enqueue } from "../../jobs/queue.js"; import { paymentsInitialized, @@ -132,7 +136,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) { @@ -159,6 +163,24 @@ export const submitDonation = async (req, res) => { }); } + let result; + if (requestSponsorship === true) { + try { + result = await submitSponsoredTransaction(signedXdr, donation, donorId); + } catch (error) { + await session.abortTransaction(); + if (error instanceof FeeSponsorshipError) { + return res.status(error.status).json({ + success: false, + message: error.message, + code: error.code, + canSubmitNormally: true, + }); + } + throw error; + } + } + // Update status to submitted donation.status = "submitted"; donation.submittedAt = new Date(); @@ -166,9 +188,8 @@ export const submitDonation = async (req, res) => { 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; @@ -185,6 +206,13 @@ export const submitDonation = async (req, res) => { }); } + if (requestSponsorship === true) { + donation.sponsored = true; + donation.sponsorFeeStroops = result.feeCharged; + donation.innerTxHash = result.innerHash; + donation.feeBumpTxHash = result.hash; + } + // Verify on-chain that the donation actually paid the fund (amount, destination, asset) const verification = await verifyPaymentOperations(result.hash, [ { diff --git a/src/controllers/stellar/paymentController.js b/src/controllers/stellar/paymentController.js index 28272a42..05ac4a4a 100644 --- a/src/controllers/stellar/paymentController.js +++ b/src/controllers/stellar/paymentController.js @@ -22,6 +22,11 @@ import * as StellarSdk from "@stellar/stellar-sdk"; import { recordSaleEarnings } from "../../services/payoutService.js"; import { enqueue } from "../../jobs/queue.js"; import logger from "../../config/logger.js"; +import { + FeeSponsorshipError, + getFeeSponsorStatus, + submitSponsoredTransaction, +} from "../../services/stellar/feeSponsorService.js"; import { paymentsInitialized, paymentsSubmitted, @@ -402,7 +407,7 @@ export const submitPayment = async (req, res) => { session.startTransaction(); try { - const { transactionId, signedXdr } = req.body; + const { transactionId, signedXdr, requestSponsorship = false } = req.body; const buyerId = req.user._id; if (!transactionId || !signedXdr) { @@ -428,6 +433,26 @@ export const submitPayment = async (req, res) => { }); } + // Validate, cap and submit sponsorship before mutating the payment row so + // clients can safely fall back to normal submission after a rejection. + let result; + if (requestSponsorship === true) { + try { + result = await submitSponsoredTransaction(signedXdr, transaction, buyerId); + } catch (error) { + await session.abortTransaction(); + if (error instanceof FeeSponsorshipError) { + return res.status(error.status).json({ + success: false, + message: error.message, + code: error.code, + canSubmitNormally: true, + }); + } + throw error; + } + } + // Update status to submitted transaction.status = "submitted"; transaction.submittedAt = new Date(); @@ -435,9 +460,8 @@ export const submitPayment = async (req, res) => { paymentsSubmitted.inc({ type: "purchase" }); // Submit to Stellar network - let result; try { - result = await submitTransaction(signedXdr); + result = result || (await submitTransaction(signedXdr)); } catch (stellarError) { // Handle Stellar submission errors transaction.status = "failed"; @@ -455,6 +479,13 @@ export const submitPayment = async (req, res) => { }); } + if (requestSponsorship === true) { + transaction.sponsored = true; + transaction.sponsorFeeStroops = result.feeCharged; + transaction.innerTxHash = result.innerHash; + transaction.feeBumpTxHash = result.hash; + } + // Verify on-chain that the creator (and platform, when a fee was applied) // actually received the expected USDC amounts const expectedPayments = transaction.platformFee?.platformAmount @@ -604,6 +635,17 @@ export const submitPayment = async (req, res) => { } }; +/** GET /api/stellar/payment/sponsorship/status */ +export const getSponsorshipStatus = async (req, res) => { + try { + res.status(200).json({ success: true, sponsorship: await getFeeSponsorStatus() }); + } catch (error) { + logger.error("Fee sponsorship status error:", error); + const status = error instanceof FeeSponsorshipError ? error.status : 500; + res.status(status).json({ success: false, message: error.message }); + } +}; + /** * Get transaction history for a user * GET /api/stellar/payment/transactions diff --git a/src/models/FeeSponsorDailySpend.js b/src/models/FeeSponsorDailySpend.js new file mode 100644 index 00000000..47e53651 --- /dev/null +++ b/src/models/FeeSponsorDailySpend.js @@ -0,0 +1,12 @@ +import mongoose from "mongoose"; + +const feeSponsorDailySpendSchema = new mongoose.Schema( + { + dateKey: { type: String, required: true, unique: true, index: true }, + totalStroops: { type: Number, default: 0, min: 0 }, + perUser: { type: Map, of: Number, default: {} }, + }, + { timestamps: true } +); + +export default mongoose.model("FeeSponsorDailySpend", feeSponsorDailySpendSchema); diff --git a/src/models/Transaction.js b/src/models/Transaction.js index 9d460fc8..38139891 100644 --- a/src/models/Transaction.js +++ b/src/models/Transaction.js @@ -13,6 +13,10 @@ const transactionSchema = new mongoose.Schema( stellarLedger: { type: Number, }, + sponsored: { type: Boolean, default: false }, + sponsorFeeStroops: { type: Number, min: 0 }, + innerTxHash: { type: String }, + feeBumpTxHash: { type: String }, // Transaction kind: item purchase or sadaqah donation type: { diff --git a/src/routes/stellar/paymentRoutes.js b/src/routes/stellar/paymentRoutes.js index 114e68ec..52069702 100644 --- a/src/routes/stellar/paymentRoutes.js +++ b/src/routes/stellar/paymentRoutes.js @@ -8,6 +8,7 @@ import { getTransactionHistory, getTransaction, cancelTransaction, + getSponsorshipStatus, } from "../../controllers/stellar/paymentController.js"; const router = express.Router(); @@ -19,6 +20,7 @@ router.use(protect); router.post("/quote", getQuote); router.post("/initialize", initializePayment); router.post("/submit", submitPayment); +router.get("/sponsorship/status", getSponsorshipStatus); // Transaction management router.get("/transactions", getTransactionHistory); diff --git a/src/services/stellar/feeSponsorService.js b/src/services/stellar/feeSponsorService.js new file mode 100644 index 00000000..45047efa --- /dev/null +++ b/src/services/stellar/feeSponsorService.js @@ -0,0 +1,229 @@ +import * as StellarSdk from "@stellar/stellar-sdk"; +import FeeSponsorDailySpend from "../../models/FeeSponsorDailySpend.js"; +import logger from "../../config/logger.js"; +import { + server, + USDC_ISSUER, + networkPassphrase, + DONATION_WALLET_PUBLIC_KEY, +} from "./stellarService.js"; + +export class FeeSponsorshipError extends Error { + constructor(message, code, status = 422) { + super(message); + this.name = "FeeSponsorshipError"; + this.code = code; + this.status = status; + } +} + +const positiveInteger = (value, fallback) => { + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; +}; + +export const getFeeSponsorConfig = () => ({ + enabled: process.env.FEE_SPONSOR_ENABLED === "true", + maxFeeStroops: positiveInteger(process.env.FEE_SPONSOR_MAX_FEE_STROOPS, 1000000), + dailyCapStroops: positiveInteger(process.env.FEE_SPONSOR_DAILY_CAP_STROOPS, 10000000), + perUserDailyLimit: positiveInteger(process.env.FEE_SPONSOR_PER_USER_DAILY_LIMIT, 5), +}); + +const expectedMemo = (row) => + row.type === "donation" + ? "DNB-SADAQAH" + : `DNB-${row.itemType.toUpperCase()}-${row.itemId.toString().slice(-8)}`; + +const assetMatches = (asset) => + asset?.getCode?.() === "USDC" && asset?.getIssuer?.() === USDC_ISSUER; + +const sameAmount = (left, right) => { + const normalize = (value) => Number(value).toFixed(7); + return normalize(left) === normalize(right); +}; + +export const validateInnerTransaction = (signedXdr, row) => { + let transaction; + try { + transaction = StellarSdk.TransactionBuilder.fromXDR(signedXdr, networkPassphrase); + } catch { + throw new FeeSponsorshipError("Invalid signed transaction XDR", "invalid_xdr"); + } + + if (transaction.innerTransaction) { + throw new FeeSponsorshipError("Nested fee-bump transactions are not allowed", "nested_fee_bump"); + } + if (!transaction.signatures?.length) { + throw new FeeSponsorshipError("The inner transaction must be signed", "unsigned_transaction"); + } + if (transaction.source !== row.buyerWallet) { + throw new FeeSponsorshipError("Transaction source does not match the buyer wallet", "wrong_source"); + } + const sourceKeypair = StellarSdk.Keypair.fromPublicKey(row.buyerWallet); + const transactionHash = transaction.hash(); + const hasValidSourceSignature = transaction.signatures.some((signature) => + sourceKeypair.verify(transactionHash, signature.signature()) + ); + if (!hasValidSourceSignature) { + throw new FeeSponsorshipError("The buyer signature is invalid", "invalid_signature"); + } + if (row.stellarTxHash && transactionHash.toString("hex") !== row.stellarTxHash) { + throw new FeeSponsorshipError("Signed transaction differs from the initialized transaction", "transaction_changed"); + } + + const expected = row.platformFee?.platformAmount + ? [ + { destination: row.creatorWallet, amount: row.platformFee.creatorAmount }, + { destination: row.platformFee.platformWallet, amount: row.platformFee.platformAmount }, + ] + : [{ destination: row.type === "donation" ? DONATION_WALLET_PUBLIC_KEY : row.creatorWallet, amount: row.amount }]; + + if (transaction.operations.length !== expected.length) { + throw new FeeSponsorshipError("Unexpected operation count", "wrong_operation_count"); + } + + transaction.operations.forEach((operation, index) => { + const wanted = expected[index]; + if ( + operation.type !== "payment" || + !assetMatches(operation.asset) || + operation.destination !== wanted.destination || + !sameAmount(operation.amount, wanted.amount) + ) { + throw new FeeSponsorshipError("Transaction contains a non-whitelisted payment", "operation_not_allowed"); + } + }); + + const memoType = transaction.memo?.type ?? transaction.memo?._type; + const rawMemoValue = transaction.memo?.value ?? transaction.memo?._value; + const memoValue = Buffer.isBuffer(rawMemoValue) ? rawMemoValue.toString("utf8") : rawMemoValue; + if (memoType !== "text" || memoValue !== expectedMemo(row)) { + throw new FeeSponsorshipError("Transaction memo does not match", "wrong_memo"); + } + return transaction; +}; + +const sponsorKeypair = () => { + if (!process.env.FEE_SPONSOR_SECRET) { + throw new FeeSponsorshipError("Fee sponsorship is not configured", "sponsor_unavailable", 503); + } + try { + return StellarSdk.Keypair.fromSecret(process.env.FEE_SPONSOR_SECRET); + } catch { + throw new FeeSponsorshipError("Fee sponsorship is not configured", "sponsor_unavailable", 503); + } +}; + +export const wrapWithFeeBump = async (innerTransaction) => { + const config = getFeeSponsorConfig(); + if (!config.enabled) { + throw new FeeSponsorshipError("Fee sponsorship is disabled", "sponsorship_disabled", 409); + } + const keypair = sponsorKeypair(); + const operationCount = innerTransaction.operations.length + 1; + const networkBaseFee = positiveInteger(await server.fetchBaseFee(), Number(StellarSdk.BASE_FEE)); + const baseFee = Math.min(networkBaseFee, Math.floor(config.maxFeeStroops / operationCount)); + if (baseFee < Number(StellarSdk.BASE_FEE)) { + throw new FeeSponsorshipError("Configured fee cap is too low", "fee_cap_too_low", 503); + } + const feeBump = StellarSdk.TransactionBuilder.buildFeeBumpTransaction( + keypair, + baseFee.toString(), + innerTransaction, + networkPassphrase + ); + feeBump.sign(keypair); + const reservedStroops = Number(feeBump.fee); + if (reservedStroops > config.maxFeeStroops) { + throw new FeeSponsorshipError("Transaction exceeds the sponsorship fee cap", "per_transaction_cap", 429); + } + return { feeBump, reservedStroops, innerHash: innerTransaction.hash().toString("hex") }; +}; + +const dateKey = () => new Date().toISOString().slice(0, 10); + +export const reserveSponsorship = async (userId, stroops) => { + const config = getFeeSponsorConfig(); + const userKey = userId.toString().replaceAll(".", "_").replaceAll("$", "_"); + const userPath = `perUser.${userKey}`; + const filter = { + dateKey: dateKey(), + totalStroops: { $lte: config.dailyCapStroops - stroops }, + $or: [{ [userPath]: { $lt: config.perUserDailyLimit } }, { [userPath]: { $exists: false } }], + }; + try { + const spend = await FeeSponsorDailySpend.findOneAndUpdate( + filter, + { $inc: { totalStroops: stroops, [userPath]: 1 }, $setOnInsert: { dateKey: dateKey() } }, + { new: true, upsert: true } + ); + if (!spend) throw new Error("cap"); + } catch (error) { + if (error?.code === 11000 || error.message === "cap") { + throw new FeeSponsorshipError("Daily sponsorship allowance has been reached", "daily_limit", 429); + } + throw error; + } + return { dateKey: dateKey(), userPath, stroops }; +}; + +export const releaseSponsorship = async (reservation, actualFee = 0) => { + if (!reservation) return; + const release = reservation.stroops - Math.max(0, actualFee); + const update = { $inc: { totalStroops: -release } }; + if (actualFee === 0) update.$inc[reservation.userPath] = -1; + await FeeSponsorDailySpend.updateOne({ dateKey: reservation.dateKey }, update); +}; + +export const submitSponsoredTransaction = async (signedXdr, row, userId) => { + const innerTransaction = validateInnerTransaction(signedXdr, row); + const wrapped = await wrapWithFeeBump(innerTransaction); + let reservation; + try { + reservation = await reserveSponsorship(userId, wrapped.reservedStroops); + const result = await server.submitTransaction(wrapped.feeBump); + const actualFee = Number(result.fee_charged || wrapped.reservedStroops); + try { + await releaseSponsorship(reservation, actualFee); + } catch (accountingError) { + // The full reservation remains charged, which is conservative and keeps + // caps safe. Never tell a client to resubmit an already accepted payment. + logger.error("Failed to reconcile sponsored fee reservation", { + transactionId: row._id?.toString(), + message: accountingError.message, + }); + } + logger.info("Fee sponsorship accepted", { transactionId: row._id?.toString(), feeStroops: actualFee }); + return { + hash: result.hash, + ledger: result.ledger, + successful: result.successful, + feeCharged: actualFee, + innerHash: wrapped.innerHash, + }; + } catch (error) { + await releaseSponsorship(reservation); + logger.warn("Fee sponsorship rejected", { transactionId: row._id?.toString(), code: error.code || "submission_failed" }); + if (error instanceof FeeSponsorshipError) throw error; + throw new FeeSponsorshipError("Sponsored submission failed; submit normally instead", "sponsored_submission_failed", 422); + } +}; + +export const getFeeSponsorStatus = async () => { + const config = getFeeSponsorConfig(); + if (!config.enabled) return { enabled: false }; + const keypair = sponsorKeypair(); + const [account, spend] = await Promise.all([ + server.loadAccount(keypair.publicKey()), + FeeSponsorDailySpend.findOne({ dateKey: dateKey() }).lean(), + ]); + const native = account.balances.find((balance) => balance.asset_type === "native"); + return { + enabled: true, + sponsorAccount: keypair.publicKey(), + nativeBalance: native?.balance || "0", + spentTodayStroops: spend?.totalStroops || 0, + dailyCapStroops: config.dailyCapStroops, + perUserDailyLimit: config.perUserDailyLimit, + }; +}; diff --git a/src/services/stellar/stellarService.js b/src/services/stellar/stellarService.js index e6977683..f1725d80 100644 --- a/src/services/stellar/stellarService.js +++ b/src/services/stellar/stellarService.js @@ -354,6 +354,7 @@ export const submitTransaction = async (signedXdr) => { hash: result.hash, ledger: result.ledger, successful: result.successful, + feeCharged: Number(result.fee_charged || 0), }; } catch (error) { logger.error("Error submitting transaction:", error); diff --git a/test/feeSponsor.test.js b/test/feeSponsor.test.js new file mode 100644 index 00000000..f53e1208 --- /dev/null +++ b/test/feeSponsor.test.js @@ -0,0 +1,134 @@ +import * as StellarSdk from "@stellar/stellar-sdk"; +import { jest } from "@jest/globals"; +import { + FeeSponsorshipError, + validateInnerTransaction, + wrapWithFeeBump, + reserveSponsorship, +} from "../src/services/stellar/feeSponsorService.js"; +import FeeSponsorDailySpend from "../src/models/FeeSponsorDailySpend.js"; +import { + server, + USDC, + networkPassphrase, +} from "../src/services/stellar/stellarService.js"; + +const source = StellarSdk.Keypair.random(); +const creator = StellarSdk.Keypair.random(); +const platform = StellarSdk.Keypair.random(); +const itemId = "507f1f77bcf86cd799439011"; + +const row = { + type: "purchase", + buyerWallet: source.publicKey(), + creatorWallet: creator.publicKey(), + itemType: "book", + itemId, + amount: "10", +}; + +const build = ({ + signer = source, + sourceKey = source, + operations, + memo = "DNB-BOOK-99439011", +} = {}) => { + let builder = new StellarSdk.TransactionBuilder( + new StellarSdk.Account(sourceKey.publicKey(), "1"), + { fee: StellarSdk.BASE_FEE, networkPassphrase } + ); + for (const operation of operations || [ + StellarSdk.Operation.payment({ + destination: creator.publicKey(), + asset: USDC, + amount: "10", + }), + ]) builder = builder.addOperation(operation); + const tx = builder.addMemo(StellarSdk.Memo.text(memo)).setTimeout(300).build(); + if (signer) tx.sign(signer); + return tx; +}; + +describe("fee sponsorship transaction whitelist", () => { + it("accepts the exact signed payment built for the transaction row", () => { + expect(validateInnerTransaction(build().toXDR(), row).source).toBe(source.publicKey()); + }); + + it.each([ + ["wrong source", () => build({ sourceKey: platform, signer: platform }), "wrong_source"], + ["wrong memo", () => build({ memo: "DNB-WRONG" }), "wrong_memo"], + ["extra operation", () => build({ operations: [ + StellarSdk.Operation.payment({ destination: creator.publicKey(), asset: USDC, amount: "10" }), + StellarSdk.Operation.payment({ destination: platform.publicKey(), asset: USDC, amount: "1" }), + ] }), "wrong_operation_count"], + ["wrong asset", () => build({ operations: [StellarSdk.Operation.payment({ + destination: creator.publicKey(), asset: StellarSdk.Asset.native(), amount: "10", + })] }), "operation_not_allowed"], + ["wrong destination", () => build({ operations: [StellarSdk.Operation.payment({ + destination: platform.publicKey(), asset: USDC, amount: "10", + })] }), "operation_not_allowed"], + ["wrong amount", () => build({ operations: [StellarSdk.Operation.payment({ + destination: creator.publicKey(), asset: USDC, amount: "9", + })] }), "operation_not_allowed"], + ])("rejects %s", (_label, makeTransaction, code) => { + expect(() => validateInnerTransaction(makeTransaction().toXDR(), row)).toThrow(FeeSponsorshipError); + try { validateInnerTransaction(makeTransaction().toXDR(), row); } catch (error) { + expect(error.code).toBe(code); + } + }); + + it("requires both exact operations for a configured fee split", () => { + const splitRow = { + ...row, + platformFee: { + creatorAmount: "9", + platformAmount: "1", + platformWallet: platform.publicKey(), + }, + }; + const split = build({ operations: [ + StellarSdk.Operation.payment({ destination: creator.publicKey(), asset: USDC, amount: "9" }), + StellarSdk.Operation.payment({ destination: platform.publicKey(), asset: USDC, amount: "1" }), + ] }); + expect(validateInnerTransaction(split.toXDR(), splitRow).operations).toHaveLength(2); + }); +}); + +describe("fee-bump construction", () => { + const originalEnv = { ...process.env }; + afterEach(() => { + process.env = { ...originalEnv }; + jest.restoreAllMocks(); + }); + + it("signs a fee-bump envelope without exceeding the configured total cap", async () => { + process.env.FEE_SPONSOR_ENABLED = "true"; + process.env.FEE_SPONSOR_SECRET = StellarSdk.Keypair.random().secret(); + process.env.FEE_SPONSOR_MAX_FEE_STROOPS = "250"; + jest.spyOn(server, "fetchBaseFee").mockResolvedValue(100); + const wrapped = await wrapWithFeeBump(build()); + expect(wrapped.feeBump.innerTransaction.hash().toString("hex")).toBe(wrapped.innerHash); + expect(Number(wrapped.feeBump.fee)).toBeLessThanOrEqual(250); + expect(wrapped.feeBump.signatures).toHaveLength(1); + }); + + it("rejects when the total fee cap cannot cover wrapper semantics", async () => { + process.env.FEE_SPONSOR_ENABLED = "true"; + process.env.FEE_SPONSOR_SECRET = StellarSdk.Keypair.random().secret(); + process.env.FEE_SPONSOR_MAX_FEE_STROOPS = "150"; + jest.spyOn(server, "fetchBaseFee").mockResolvedValue(100); + await expect(wrapWithFeeBump(build())).rejects.toMatchObject({ code: "fee_cap_too_low" }); + }); + + it("enforces daily spend and per-user count in one atomic reservation", async () => { + process.env.FEE_SPONSOR_DAILY_CAP_STROOPS = "1000"; + process.env.FEE_SPONSOR_PER_USER_DAILY_LIMIT = "2"; + const update = jest.spyOn(FeeSponsorDailySpend, "findOneAndUpdate").mockResolvedValue(null); + await expect(reserveSponsorship("user-1", 300)).rejects.toMatchObject({ code: "daily_limit" }); + const [filter, mutation, options] = update.mock.calls[0]; + expect(filter.totalStroops.$lte).toBe(700); + expect(filter.$or[0]["perUser.user-1"].$lt).toBe(2); + expect(mutation.$inc.totalStroops).toBe(300); + expect(options).toMatchObject({ upsert: true, new: true }); + }); +}); From 9568232f800351bf5ff1c12d1da980b0f9150c1b Mon Sep 17 00:00:00 2001 From: Dayz Tech Co Date: Wed, 22 Jul 2026 10:10:27 +0100 Subject: [PATCH 2/2] fix(stellar): harden sponsorship retries and validation --- src/config/validateEnv.js | 10 ++ src/controllers/stellar/donationController.js | 120 ++++++++++++++-- src/controllers/stellar/paymentController.js | 129 +++++++++++++++--- src/models/Transaction.js | 15 +- src/services/stellar/feeSponsorService.js | 56 +++++++- test/feeSponsor.test.js | 13 ++ 6 files changed, 307 insertions(+), 36 deletions(-) diff --git a/src/config/validateEnv.js b/src/config/validateEnv.js index 8f4bcdcb..2099b9f8 100644 --- a/src/config/validateEnv.js +++ b/src/config/validateEnv.js @@ -58,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( diff --git a/src/controllers/stellar/donationController.js b/src/controllers/stellar/donationController.js index c3eebdda..763b3df5 100644 --- a/src/controllers/stellar/donationController.js +++ b/src/controllers/stellar/donationController.js @@ -15,7 +15,9 @@ import { import logger from "../../config/logger.js"; import { FeeSponsorshipError, - submitSponsoredTransaction, + prepareSponsoredTransaction, + reconcileSponsoredTransaction, + submitPreparedSponsoredTransaction, } from "../../services/stellar/feeSponsorService.js"; import { enqueue } from "../../jobs/queue.js"; import { @@ -146,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", @@ -165,19 +175,102 @@ export const submitDonation = async (req, res) => { let result; if (requestSponsorship === true) { - try { - result = await submitSponsoredTransaction(signedXdr, donation, donorId); - } catch (error) { - await session.abortTransaction(); - if (error instanceof FeeSponsorshipError) { - return res.status(error.status).json({ + 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: error.message, - code: error.code, - canSubmitNormally: true, + message: "Donation state changed while sponsorship was submitted", + data: { code: "donation_state_changed", canSubmitNormally: false }, }); } - throw error; + } + + 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(), + }); } } @@ -208,9 +301,10 @@ export const submitDonation = async (req, res) => { if (requestSponsorship === true) { donation.sponsored = true; - donation.sponsorFeeStroops = result.feeCharged; + 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) diff --git a/src/controllers/stellar/paymentController.js b/src/controllers/stellar/paymentController.js index 05ac4a4a..fb4a2661 100644 --- a/src/controllers/stellar/paymentController.js +++ b/src/controllers/stellar/paymentController.js @@ -25,7 +25,9 @@ import logger from "../../config/logger.js"; import { FeeSponsorshipError, getFeeSponsorStatus, - submitSponsoredTransaction, + prepareSponsoredTransaction, + reconcileSponsoredTransaction, + submitPreparedSponsoredTransaction, } from "../../services/stellar/feeSponsorService.js"; import { paymentsInitialized, @@ -417,9 +419,17 @@ export const submitPayment = async (req, res) => { message: "Transaction 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 transaction - const transaction = await Transaction.findOne({ + let transaction = await Transaction.findOne({ _id: transactionId, buyer: buyerId, status: "pending", @@ -433,23 +443,103 @@ export const submitPayment = async (req, res) => { }); } - // Validate, cap and submit sponsorship before mutating the payment row so - // clients can safely fall back to normal submission after a rejection. let result; if (requestSponsorship === true) { - try { - result = await submitSponsoredTransaction(signedXdr, transaction, buyerId); - } catch (error) { - await session.abortTransaction(); - if (error instanceof FeeSponsorshipError) { - return res.status(error.status).json({ + if ( + transaction.sponsorshipAttemptKey && + ["prepared", "unknown"].includes(transaction.sponsorshipStatus) + ) { + result = await reconcileSponsoredTransaction( + transaction.sponsorshipAttemptKey + ); + if (!result) { + await session.abortTransaction(); + return res.status(409).json({ success: false, - message: error.message, - code: error.code, - canSubmitNormally: true, + message: "The previous sponsorship outcome is still being reconciled", + data: { + code: "sponsorship_outcome_unknown", + canSubmitNormally: false, + }, + }); + } + } else { + let prepared; + try { + prepared = await prepareSponsoredTransaction(signedXdr, transaction); + transaction.sponsorshipAttemptKey = prepared.innerHash; + transaction.sponsorshipStatus = "prepared"; + transaction.sponsorshipAttemptedAt = new Date(); + await transaction.save({ session }); + await session.commitTransaction(); + result = await submitPreparedSponsoredTransaction( + prepared, + transaction, + buyerId + ); + } catch (error) { + const safeToRetry = + error instanceof FeeSponsorshipError && + error.code !== "sponsored_submission_failed"; + if (safeToRetry) { + if (session.inTransaction()) await session.abortTransaction(); + await Transaction.updateOne( + { _id: transaction._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: transaction._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(); + transaction = await Transaction.findOne({ + _id: transactionId, + buyer: buyerId, + status: "pending", + }).session(session); + if (!transaction) { + await session.abortTransaction(); + return res.status(409).json({ + success: false, + message: "Payment state changed while sponsorship was submitted", + data: { code: "payment_state_changed", canSubmitNormally: false }, }); } - throw error; + } + + if (!session.inTransaction()) session.startTransaction(); + if (!transaction.$session()) transaction.$session(session); + if (result.reconciled && transaction.sponsorshipStatus !== "submitted") { + logger.info("Recovered sponsored payment by inner transaction hash", { + transactionId: transaction._id.toString(), + }); } } @@ -481,9 +571,10 @@ export const submitPayment = async (req, res) => { if (requestSponsorship === true) { transaction.sponsored = true; - transaction.sponsorFeeStroops = result.feeCharged; + transaction.sponsorFeeStroops = result.feeCharged.toString(); transaction.innerTxHash = result.innerHash; transaction.feeBumpTxHash = result.hash; + transaction.sponsorshipStatus = "submitted"; } // Verify on-chain that the creator (and platform, when a fee was applied) @@ -638,11 +729,15 @@ export const submitPayment = async (req, res) => { /** GET /api/stellar/payment/sponsorship/status */ export const getSponsorshipStatus = async (req, res) => { try { - res.status(200).json({ success: true, sponsorship: await getFeeSponsorStatus() }); + res.status(200).json({ + success: true, + message: "Fee sponsorship status retrieved", + data: { sponsorship: await getFeeSponsorStatus() }, + }); } catch (error) { logger.error("Fee sponsorship status error:", error); const status = error instanceof FeeSponsorshipError ? error.status : 500; - res.status(status).json({ success: false, message: error.message }); + res.status(status).json({ success: false, message: error.message, data: null }); } }; diff --git a/src/models/Transaction.js b/src/models/Transaction.js index 38139891..28b4011a 100644 --- a/src/models/Transaction.js +++ b/src/models/Transaction.js @@ -14,9 +14,22 @@ const transactionSchema = new mongoose.Schema( type: Number, }, sponsored: { type: Boolean, default: false }, - sponsorFeeStroops: { type: Number, min: 0 }, + sponsorFeeStroops: { + type: String, + validate: { + validator: (value) => value == null || /^(0|[1-9]\d*)$/.test(value), + message: "Sponsor fee must be a non-negative integer stroop string", + }, + }, innerTxHash: { type: String }, feeBumpTxHash: { type: String }, + sponsorshipAttemptKey: { type: String, index: true }, + sponsorshipStatus: { + type: String, + enum: ["prepared", "submitted", "rejected", "unknown"], + }, + sponsorshipAttemptedAt: { type: Date }, + sponsorshipFailureCode: { type: String }, // Transaction kind: item purchase or sadaqah donation type: { diff --git a/src/services/stellar/feeSponsorService.js b/src/services/stellar/feeSponsorService.js index 45047efa..15e1fbe0 100644 --- a/src/services/stellar/feeSponsorService.js +++ b/src/services/stellar/feeSponsorService.js @@ -6,6 +6,7 @@ import { USDC_ISSUER, networkPassphrase, DONATION_WALLET_PUBLIC_KEY, + toStroops, } from "./stellarService.js"; export class FeeSponsorshipError extends Error { @@ -38,8 +39,11 @@ const assetMatches = (asset) => asset?.getCode?.() === "USDC" && asset?.getIssuer?.() === USDC_ISSUER; const sameAmount = (left, right) => { - const normalize = (value) => Number(value).toFixed(7); - return normalize(left) === normalize(right); + try { + return toStroops(left) === toStroops(right); + } catch { + return false; + } }; export const validateInnerTransaction = (signedXdr, row) => { @@ -175,9 +179,12 @@ export const releaseSponsorship = async (reservation, actualFee = 0) => { await FeeSponsorDailySpend.updateOne({ dateKey: reservation.dateKey }, update); }; -export const submitSponsoredTransaction = async (signedXdr, row, userId) => { +export const prepareSponsoredTransaction = async (signedXdr, row) => { const innerTransaction = validateInnerTransaction(signedXdr, row); - const wrapped = await wrapWithFeeBump(innerTransaction); + return wrapWithFeeBump(innerTransaction); +}; + +export const submitPreparedSponsoredTransaction = async (wrapped, row, userId) => { let reservation; try { reservation = await reserveSponsorship(userId, wrapped.reservedStroops); @@ -202,13 +209,52 @@ export const submitSponsoredTransaction = async (signedXdr, row, userId) => { innerHash: wrapped.innerHash, }; } catch (error) { - await releaseSponsorship(reservation); + // Once Horizon submission has been attempted the outcome may be ambiguous. + // Keep the full reservation charged; reconciliation can safely under-count + // neither an accepted transaction nor its fee. logger.warn("Fee sponsorship rejected", { transactionId: row._id?.toString(), code: error.code || "submission_failed" }); if (error instanceof FeeSponsorshipError) throw error; throw new FeeSponsorshipError("Sponsored submission failed; submit normally instead", "sponsored_submission_failed", 422); } }; +export const submitSponsoredTransaction = async (signedXdr, row, userId) => { + const wrapped = await prepareSponsoredTransaction(signedXdr, row); + return submitPreparedSponsoredTransaction(wrapped, row, userId); +}; + +export const reconcileSponsoredTransaction = async (innerHash) => { + const keypair = sponsorKeypair(); + try { + const page = await server + .transactions() + .forAccount(keypair.publicKey()) + .order("desc") + .limit(200) + .call(); + const transaction = page.records.find( + (record) => + record.inner_transaction_hash === innerHash || + record.innerTransactionHash === innerHash + ); + if (!transaction) return null; + return { + hash: transaction.hash, + ledger: transaction.ledger, + successful: transaction.successful, + feeCharged: Number(transaction.fee_charged || transaction.feeCharged || 0), + innerHash, + reconciled: true, + }; + } catch (error) { + logger.warn("Unable to reconcile sponsored transaction", { + innerHash, + message: error.message, + }); + return null; + } +}; + export const getFeeSponsorStatus = async () => { const config = getFeeSponsorConfig(); if (!config.enabled) return { enabled: false }; diff --git a/test/feeSponsor.test.js b/test/feeSponsor.test.js index f53e1208..1e214f7b 100644 --- a/test/feeSponsor.test.js +++ b/test/feeSponsor.test.js @@ -92,6 +92,19 @@ describe("fee sponsorship transaction whitelist", () => { ] }); expect(validateInnerTransaction(split.toXDR(), splitRow).operations).toHaveLength(2); }); + + it("compares high-value amounts with exact stroop precision", () => { + const highValueRow = { ...row, amount: "922337203685.4775806" }; + const highValuePayment = build({ operations: [ + StellarSdk.Operation.payment({ + destination: creator.publicKey(), + asset: USDC, + amount: "922337203685.4775807", + }), + ] }); + expect(() => validateInnerTransaction(highValuePayment.toXDR(), highValueRow)) + .toThrow("non-whitelisted payment"); + }); }); describe("fee-bump construction", () => {