Skip to content
Merged
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
3 changes: 3 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ PLATFORM_FEE_BPS=150
# When unset, alerting is silently skipped — no hard dependency.
# ALERT_WEBHOOK_URL=https://hooks.slack.com/services/...

# 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
# Optional: UUID of the user who may approve/reject withdrawals as platform (JWT subject must match).
# When unset, any logged-in user may call the withdrawal approval endpoints (dev mode only).
# PLATFORM_APPROVER_USER_ID=00000000-0000-0000-0000-000000000000
Expand Down
10 changes: 8 additions & 2 deletions backend/src/routes/campaigns.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
27 changes: 27 additions & 0 deletions backend/src/routes/campaigns.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion backend/src/routes/milestones.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
23 changes: 18 additions & 5 deletions backend/src/routes/withdrawals.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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:
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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:
Expand Down
45 changes: 42 additions & 3 deletions backend/src/routes/withdrawals.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => ({
Expand Down Expand Up @@ -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';
Expand All @@ -64,18 +72,32 @@ 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();
assert.equal(res.status, 200);
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({
Expand Down Expand Up @@ -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',
Expand Down
78 changes: 63 additions & 15 deletions backend/src/services/ledgerMonitor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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]
);

Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand 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()
Expand All @@ -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) => {
Expand Down Expand Up @@ -534,6 +581,7 @@ module.exports = {
startLedgerMonitor,
watchCampaignWallet,
handlePayment,
reconcileCampaignBalances,
getLedgerStreamHealth,
addSSEClient,
removeSSEClient,
Expand Down
Loading
Loading