From b07d500319b263357ad95a60456c395916e3be94 Mon Sep 17 00:00:00 2001 From: devsimze Date: Fri, 29 May 2026 22:31:32 +0100 Subject: [PATCH] Fix funded status, ledger resilience, orphan wallets, and platform approval. Atomic campaign funding on payment, balance reconciliation, safer campaign creation errors, and fail-closed platform approver gating for withdrawals. Co-authored-by: Cursor --- backend/.env.example | 4 +- backend/src/config/env.js | 6 ++ backend/src/routes/campaigns.js | 10 +- backend/src/routes/campaigns.test.js | 27 +++++ backend/src/routes/milestones.js | 2 +- backend/src/routes/withdrawals.js | 23 ++++- backend/src/routes/withdrawals.test.js | 45 ++++++++- backend/src/services/ledgerMonitor.js | 78 ++++++++++++--- backend/src/services/ledgerMonitor.test.js | 109 +++++++++++++++++---- 9 files changed, 256 insertions(+), 48 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 9296fa3..4c8950f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -34,8 +34,8 @@ PLATFORM_FEE_BPS=150 # When unset, alerting is silently skipped — no hard dependency. # ALERT_WEBHOOK_URL=https://hooks.slack.com/services/... -# Optional: UUID of the user who may approve/reject withdrawals as platform (JWT subject must match). -# When unset, any logged-in user may call# Platform User for Approvals (Optional) +# Required in production: UUID of the user who may approve/reject platform withdrawals +# and milestone releases (JWT subject must match). When unset, platform approval is denied. PLATFORM_APPROVER_USER_ID=00000000-0000-0000-0000-000000000000 # Email Configuration (password reset, welcome emails, etc.) diff --git a/backend/src/config/env.js b/backend/src/config/env.js index e18dbe5..76d4f3c 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -29,6 +29,12 @@ function validateEnv() { process.stderr.write(`\n[crowdpay] Cannot start: ${err.message}\n\n`); process.exit(1); } + + if (!process.env.PLATFORM_APPROVER_USER_ID) { + process.stderr.write( + '\n[crowdpay] WARNING: PLATFORM_APPROVER_USER_ID not set — platform approval actions are denied.\n\n' + ); + } } module.exports = { validateEnv }; diff --git a/backend/src/routes/campaigns.js b/backend/src/routes/campaigns.js index 6d7b10e..31b8a86 100644 --- a/backend/src/routes/campaigns.js +++ b/backend/src/routes/campaigns.js @@ -779,8 +779,14 @@ router.post('/', requireAuth, requireRole('creator', 'admin'), createCampaignVal await client.query('COMMIT'); } catch (err) { await client.query('ROLLBACK'); - logger.error('Campaign creation failed', { error: err.message }); - return res.status(500).json({ error: 'Could not create campaign' }); + logger.error('[campaigns] DB insert failed after wallet creation. Orphaned wallet:', { + publicKey: wallet.publicKey, + creatorUserId: req.user.userId, + error: err.message, + }); + return res.status(500).json({ + error: 'Campaign could not be saved. Wallet creation may have succeeded — contact support.', + }); } finally { client.release(); } diff --git a/backend/src/routes/campaigns.test.js b/backend/src/routes/campaigns.test.js index acba200..2ef573c 100644 --- a/backend/src/routes/campaigns.test.js +++ b/backend/src/routes/campaigns.test.js @@ -148,6 +148,33 @@ test('POST /api/campaigns allows creation when KYC gate is disabled', async (t) assert.equal(response.body.id, 'campaign-1'); }); +test('POST /api/campaigns returns 500 and logs orphaned wallet when DB insert fails', async () => { + process.env.KYC_REQUIRED_FOR_CAMPAIGNS = 'false'; + const app = buildApp({ + authUser: { userId: 'creator-1', role: 'creator' }, + queryImpl: async (text) => { + if (text.includes('SELECT email, wallet_public_key, kyc_status FROM users')) { + return { rows: [{ email: 'creator@test.com', wallet_public_key: 'GCREATOR', kyc_status: 'verified' }] }; + } + if (text === 'BEGIN' || text === 'ROLLBACK') return { rows: [] }; + if (text.includes('INSERT INTO campaigns')) { + throw new Error('unique constraint violation'); + } + return { rows: [] }; + }, + buildWithdrawalTransactionImpl: async () => '', + insertWithdrawalPendingSignaturesImpl: async () => 'tx-row', + }); + + const response = await request(app) + .post('/api/campaigns') + .set('Authorization', 'Bearer token') + .send({ title: 'Broken campaign', target_amount: '100', asset_type: 'USDC' }); + + assert.equal(response.status, 500); + assert.match(response.body.error, /contact support/i); +}); + test('POST /api/campaigns returns 400 with validation errors for invalid payload', async () => { process.env.KYC_REQUIRED_FOR_CAMPAIGNS = 'false'; const app = buildApp({ diff --git a/backend/src/routes/milestones.js b/backend/src/routes/milestones.js index 5ca22a2..f351b5c 100644 --- a/backend/src/routes/milestones.js +++ b/backend/src/routes/milestones.js @@ -20,7 +20,7 @@ const { invokeContract, nativeToScVal } = require('../services/sorobanService'); const crypto = require('crypto'); function canPerformPlatformSignature(userId) { - if (!process.env.PLATFORM_APPROVER_USER_ID) return true; + if (!process.env.PLATFORM_APPROVER_USER_ID) return false; return userId === process.env.PLATFORM_APPROVER_USER_ID; } diff --git a/backend/src/routes/withdrawals.js b/backend/src/routes/withdrawals.js index 9ef8454..7572ed5 100644 --- a/backend/src/routes/withdrawals.js +++ b/backend/src/routes/withdrawals.js @@ -1,7 +1,7 @@ const router = require('express').Router(); const db = require('../config/database'); const logger = require('../config/logger'); -const { requireAuth, requireRole } = require('../middleware/auth'); +const { requireAuth } = require('../middleware/auth'); const { sendAlert } = require('../services/alerting'); const { withdrawalValidation, validateRequest } = require('../middleware/validation'); const { @@ -23,6 +23,19 @@ const { withDecryptedWalletSecret } = require('../services/walletSecrets'); const ALLOWED_CAMPAIGN_STATUS_FOR_REQUEST = ['active', 'funded']; +/** Fail closed when PLATFORM_APPROVER_USER_ID is unset. */ +function canPerformPlatformSignature(userId) { + if (!process.env.PLATFORM_APPROVER_USER_ID) return false; + return userId === process.env.PLATFORM_APPROVER_USER_ID; +} + +function requirePlatformApprover(req, res, next) { + if (!canPerformPlatformSignature(req.user.userId)) { + return res.status(403).json({ error: 'Only the designated platform approver can perform this action' }); + } + next(); +} + /** * @openapi * tags: @@ -75,7 +88,7 @@ async function assertWithdrawalAccess(req, campaignId) { } router.get('/capabilities', requireAuth, (req, res) => { - res.json({ can_approve_platform: req.user.role === 'admin' }); + res.json({ can_approve_platform: canPerformPlatformSignature(req.user.userId) }); }); router.post('/request', requireAuth, withdrawalValidation, validateRequest, async (req, res) => { @@ -499,9 +512,9 @@ const platformApproveHandler = async (req, res) => { } }; -router.post('/:id/approve/platform', requireAuth, requireRole('admin'), platformApproveHandler); +router.post('/:id/approve/platform', requireAuth, requirePlatformApprover, platformApproveHandler); // Alias for docs + issue acceptance criteria -router.post('/:id/approve', requireAuth, requireRole('admin'), platformApproveHandler); +router.post('/:id/approve', requireAuth, requirePlatformApprover, platformApproveHandler); router.post('/:id/cancel', requireAuth, async (req, res) => { const reason = (req.body && req.body.reason) || 'Cancelled by creator'; @@ -561,7 +574,7 @@ router.post('/:id/cancel', requireAuth, async (req, res) => { } }); -router.post('/:id/reject', requireAuth, requireRole('admin'), async (req, res) => { +router.post('/:id/reject', requireAuth, requirePlatformApprover, async (req, res) => { /** * @openapi * /api/withdrawals/{id}/reject: diff --git a/backend/src/routes/withdrawals.test.js b/backend/src/routes/withdrawals.test.js index 967dd07..bc59ca2 100644 --- a/backend/src/routes/withdrawals.test.js +++ b/backend/src/routes/withdrawals.test.js @@ -4,7 +4,12 @@ const express = require('express'); const request = require('supertest'); const proxyquire = require('proxyquire').noCallThru(); -function buildApp({ queryImpl, stellarImpl, userId = 'creator-1', role = 'creator' }) { +function buildApp({ queryImpl, stellarImpl, userId = 'creator-1', role = 'creator', platformApproverUserId } = {}) { + const prevApprover = process.env.PLATFORM_APPROVER_USER_ID; + if (platformApproverUserId !== false) { + process.env.PLATFORM_APPROVER_USER_ID = platformApproverUserId ?? userId; + } + const stellarStub = { buildWithdrawalTransaction: async () => 'xdr-base', getAccountMultisigConfig: async () => ({ @@ -48,7 +53,10 @@ function buildApp({ queryImpl, stellarImpl, userId = 'creator-1', role = 'creato app.use(express.json()); app.use('/api/withdrawals', router); - return { app, cleanup: () => {} }; + return { app, cleanup: () => { + if (prevApprover === undefined) delete process.env.PLATFORM_APPROVER_USER_ID; + else process.env.PLATFORM_APPROVER_USER_ID = prevApprover; + } }; } const VALID_DESTINATION = 'GASXEYHSSVN3WSHD4WSZ4O37HC2AG4JH2EB6UPHM6IXDXDRJRDJD4RZK'; @@ -64,11 +72,12 @@ function campaignRow(overrides = {}) { }; } -test('GET /api/withdrawals/capabilities reflects admin role', async () => { +test('GET /api/withdrawals/capabilities reflects platform approver status', async () => { const { app, cleanup } = buildApp({ queryImpl: async () => ({ rows: [] }), userId: 'platform-1', role: 'admin', + platformApproverUserId: 'platform-1', }); const res = await request(app).get('/api/withdrawals/capabilities').set('Authorization', 'Bearer t'); cleanup(); @@ -76,6 +85,19 @@ test('GET /api/withdrawals/capabilities reflects admin role', async () => { assert.equal(res.body.can_approve_platform, true); }); +test('GET /api/withdrawals/capabilities denies when user is not platform approver', async () => { + const { app, cleanup } = buildApp({ + queryImpl: async () => ({ rows: [] }), + userId: 'other-user', + role: 'admin', + platformApproverUserId: 'platform-1', + }); + const res = await request(app).get('/api/withdrawals/capabilities').set('Authorization', 'Bearer t'); + cleanup(); + assert.equal(res.status, 200); + assert.equal(res.body.can_approve_platform, false); +}); + test('POST /api/withdrawals/request creates pending request and logs event', async () => { const calls = []; const { app, cleanup } = buildApp({ @@ -187,6 +209,23 @@ test('POST /api/withdrawals/request denies invalid multisig config', async () => assert.equal(response.status, 422); }); +test('POST /api/withdrawals/:id/approve/platform denies non-platform user when approver is configured', async () => { + const { app, cleanup } = buildApp({ + userId: 'other-user', + role: 'admin', + platformApproverUserId: 'platform-user', + queryImpl: async () => ({ rows: [] }), + }); + + const response = await request(app) + .post('/api/withdrawals/w-1/approve/platform') + .set('Authorization', 'Bearer token') + .send({}); + + cleanup(); + assert.equal(response.status, 403); +}); + test('POST /api/withdrawals/:id/approve/platform denies before creator approval', async () => { const { app, cleanup } = buildApp({ role: 'admin', diff --git a/backend/src/services/ledgerMonitor.js b/backend/src/services/ledgerMonitor.js index 4faa856..393a73a 100644 --- a/backend/src/services/ledgerMonitor.js +++ b/backend/src/services/ledgerMonitor.js @@ -9,6 +9,7 @@ const { server } = require('../config/stellar'); const db = require('../config/database'); const logger = require('../config/logger'); +const { getCampaignBalance } = require('./stellarService'); const { markContributionIndexed } = require('./stellarTransactionService'); const { emitWebhookEventForUser, WEBHOOK_EVENTS } = require('./webhookDispatcher'); @@ -158,7 +159,7 @@ async function handlePayment(campaignId, walletPublicKey, payment) { 'SELECT status FROM campaigns WHERE id = $1', [campaignId] ); - if (!campaignRows.length || campaignRows[0].status !== 'active') return; + if (!campaignRows.length || !['active', 'funded'].includes(campaignRows[0].status)) return; const destinationAsset = payment.asset_type === 'native' ? 'XLM' : payment.asset_code; const destinationAmount = parseFloat(payment.amount); @@ -234,16 +235,17 @@ async function handlePayment(campaignId, walletPublicKey, payment) { ] ); - await client.query( - `UPDATE campaigns SET raised_amount = raised_amount + $1 WHERE id = $2`, - [destinationAmount, campaignId] - ); - const { rows: fundedRows } = await client.query( - `UPDATE campaigns SET status = 'funded' - WHERE id = $1 AND status = 'active' AND raised_amount >= target_amount - RETURNING id, creator_id, title, raised_amount, target_amount, asset_type`, - [campaignId] + `UPDATE campaigns + SET raised_amount = raised_amount + $1, + status = CASE + WHEN raised_amount + $1 >= target_amount THEN 'funded' + ELSE status + END + WHERE id = $2 + RETURNING id, creator_id, title, raised_amount, target_amount, asset_type, + (raised_amount >= target_amount AND raised_amount - $1 < target_amount) AS newly_funded`, + [destinationAmount, campaignId] ); await markContributionIndexed(client, txHash, inserted[0].id); @@ -261,7 +263,7 @@ async function handlePayment(campaignId, walletPublicKey, payment) { } const { rows: updatedCampaign } = await client.query( - 'SELECT raised_amount FROM campaigns WHERE id = $1', + 'SELECT raised_amount, status FROM campaigns WHERE id = $1', [campaignId] ); @@ -270,7 +272,7 @@ async function handlePayment(campaignId, walletPublicKey, payment) { creatorId, contributionId: inserted[0].id, campaignId, - fundedCampaign: fundedRows[0] || null, + fundedCampaign: fundedRows[0]?.newly_funded ? fundedRows[0] : null, contributionPayload: { id: inserted[0].id, campaign_id: campaignId, @@ -307,6 +309,7 @@ async function handlePayment(campaignId, walletPublicKey, payment) { display_name: displayName, }, raised_amount: updatedCampaign[0]?.raised_amount, + status: updatedCampaign[0]?.status, }); } catch (err) { try { @@ -454,9 +457,47 @@ async function watchCampaignWallet(campaignId, walletPublicKey) { await openStreamForWallet(campaignId, walletPublicKey); } +const RECONCILE_INTERVAL_MS = 10 * 60 * 1000; + +/** + * Compare each campaign's DB raised_amount against live Horizon balance. + */ +async function reconcileCampaignBalances() { + const { rows } = await db.query( + `SELECT id, wallet_public_key, raised_amount, asset_type, status + FROM campaigns + WHERE status IN ('active', 'funded')` + ); + + for (const campaign of rows) { + try { + const balances = await getCampaignBalance(campaign.wallet_public_key); + const onChain = parseFloat(balances[campaign.asset_type] || '0'); + const inDb = parseFloat(campaign.raised_amount); + const delta = Math.abs(onChain - inDb); + if (delta > 0.0000001) { + logger.warn('Campaign raised_amount differs from Horizon balance', { + campaign_id: campaign.id, + wallet_public_key: campaign.wallet_public_key, + raised_amount_db: inDb, + balance_horizon: onChain, + asset_type: campaign.asset_type, + delta, + }); + } + } catch (err) { + logger.error('Balance reconciliation failed for campaign', { + campaign_id: campaign.id, + wallet_public_key: campaign.wallet_public_key, + error: err.message, + }); + } + } +} + async function startLedgerMonitor() { const { rows } = await db.query( - `SELECT id, wallet_public_key FROM campaigns WHERE status = 'active'` + `SELECT id, wallet_public_key FROM campaigns WHERE status IN ('active', 'funded')` ); await Promise.all( @@ -471,7 +512,13 @@ async function startLedgerMonitor() { ) ); - logger.info('Watching active campaigns', { active_campaigns: rows.length }); + logger.info('Watching active and funded campaigns', { campaign_count: rows.length }); + + setInterval(() => { + reconcileCampaignBalances().catch((err) => + logger.error('Periodic balance reconciliation failed', { error: err.message }) + ); + }, RECONCILE_INTERVAL_MS); setInterval(() => { getLedgerStreamHealth() @@ -494,7 +541,7 @@ async function getLedgerStreamHealth() { lc.last_cursor, lc.updated_at AS cursor_updated_at FROM campaigns c LEFT JOIN ledger_stream_cursors lc ON lc.campaign_id = c.id - WHERE c.status = 'active'` + WHERE c.status IN ('active', 'funded')` ); const streams = dbCursors.map((row) => { @@ -534,6 +581,7 @@ module.exports = { startLedgerMonitor, watchCampaignWallet, handlePayment, + reconcileCampaignBalances, getLedgerStreamHealth, addSSEClient, removeSSEClient, diff --git a/backend/src/services/ledgerMonitor.test.js b/backend/src/services/ledgerMonitor.test.js index 200019f..23b8750 100644 --- a/backend/src/services/ledgerMonitor.test.js +++ b/backend/src/services/ledgerMonitor.test.js @@ -2,8 +2,49 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const proxyquire = require('proxyquire').noCallThru(); -test('handlePayment updates stellar_transactions when a contribution row is created', async () => { +function buildLedgerMonitor(mockQuery) { const updates = []; + const wrappedQuery = async (text, params) => { + if (text.includes('UPDATE campaigns') && text.includes('raised_amount = raised_amount +')) { + updates.push({ text, params }); + return { + rows: [{ + id: 'camp-1', + creator_id: 'user-creator', + title: 'Test Campaign', + raised_amount: '100', + target_amount: '100', + asset_type: 'XLM', + newly_funded: true, + }], + }; + } + return mockQuery(text, params); + }; + + const mockDb = { + query: wrappedQuery, + connect: async () => ({ + query: wrappedQuery, + release: () => {}, + }), + }; + + const ledgerMonitor = proxyquire('./ledgerMonitor', { + '../config/database': mockDb, + '../config/stellar': { server: {} }, + './stellarService': { getCampaignBalance: async () => ({}) }, + './webhookDispatcher': { + emitWebhookEventForUser: async () => {}, + WEBHOOK_EVENTS: { CAMPAIGN_FUNDED: 'campaign.funded', CONTRIBUTION_RECEIVED: 'contribution.received' }, + }, + }); + + return { ledgerMonitor, updates }; +} + +test('handlePayment updates stellar_transactions when a contribution row is created', async () => { + const stellarUpdates = []; const mockQuery = async (text, params) => { if (text.includes('SELECT status FROM campaigns')) return { rows: [{ status: 'active' }] }; if (text.includes('SELECT id FROM contributions')) return { rows: [] }; @@ -15,32 +56,19 @@ test('handlePayment updates stellar_transactions when a contribution row is crea } if (text === 'BEGIN') return { rows: [] }; if (text.includes('INSERT INTO contributions')) return { rows: [{ id: 'contrib-id' }] }; - if (text.includes('UPDATE campaigns')) return { rows: [] }; if (text.includes('UPDATE stellar_transactions') && text.includes("kind = 'contribution'")) { - updates.push({ text, params }); + stellarUpdates.push({ text, params }); return { rows: [] }; } + if (text.includes('SELECT raised_amount FROM campaigns')) { + return { rows: [{ raised_amount: '100' }] }; + } if (text === 'COMMIT') return { rows: [] }; if (text === 'ROLLBACK') return { rows: [] }; return { rows: [] }; }; - const mockDb = { - query: mockQuery, - connect: async () => ({ - query: mockQuery, - release: () => {}, - }), - }; - - const ledgerMonitor = proxyquire('./ledgerMonitor', { - '../config/database': mockDb, - '../config/stellar': { server: {} }, - './webhookDispatcher': { - emitWebhookEventForUser: async () => {}, - WEBHOOK_EVENTS: {}, - }, - }); + const { ledgerMonitor, updates } = buildLedgerMonitor(mockQuery); const payment = { to: 'GWALLET', @@ -53,6 +81,47 @@ test('handlePayment updates stellar_transactions when a contribution row is crea await ledgerMonitor.handlePayment('camp-1', 'GWALLET', payment); + assert.equal(stellarUpdates.length, 1); + assert.deepEqual(stellarUpdates[0].params, ['contrib-id', 'txhash-abc']); assert.equal(updates.length, 1); - assert.deepEqual(updates[0].params, ['contrib-id', 'txhash-abc']); + assert.match(updates[0].text, /raised_amount = raised_amount \+ \$1/); + assert.match(updates[0].text, /WHEN raised_amount \+ \$1 >= target_amount THEN 'funded'/); + assert.deepEqual(updates[0].params, [1, 'camp-1']); +}); + +test('handlePayment accepts contributions on funded campaigns', async () => { + let insertCalled = false; + const mockQuery = async (text) => { + if (text.includes('SELECT status FROM campaigns')) return { rows: [{ status: 'funded' }] }; + if (text.includes('SELECT id FROM contributions')) return { rows: [] }; + if (text.includes('SELECT creator_id FROM campaigns')) { + return { rows: [{ creator_id: 'user-creator' }] }; + } + if (text.includes('SELECT metadata FROM stellar_transactions')) { + return { rows: [{ metadata: {} }] }; + } + if (text === 'BEGIN') return { rows: [] }; + if (text.includes('INSERT INTO contributions')) { + insertCalled = true; + return { rows: [{ id: 'contrib-id' }] }; + } + if (text.includes('SELECT raised_amount FROM campaigns')) { + return { rows: [{ raised_amount: '110' }] }; + } + if (text === 'COMMIT') return { rows: [] }; + return { rows: [] }; + }; + + const { ledgerMonitor } = buildLedgerMonitor(mockQuery); + + await ledgerMonitor.handlePayment('camp-1', 'GWALLET', { + to: 'GWALLET', + from: 'GFROM', + type: 'payment', + asset_type: 'native', + amount: '10', + transaction_hash: 'txhash-overfund', + }); + + assert.equal(insertCalled, true); });