From d52124c9c533587047e30a7ea788179b8d9f7dc6 Mon Sep 17 00:00:00 2001 From: oak Date: Sat, 30 May 2026 04:32:26 +0100 Subject: [PATCH 1/2] fix: extend withdrawal XDR timeout to 7 days and add expiry guard Closes #128 Changes: - stellarService.js: setTimeout(300) -> setTimeout(60*60*24*7) in buildWithdrawalTransaction; 5-minute window was too short for async multi-party signing where the platform approver may not be available for hours or days - stellarService.js: add isXdrExpired(xdr) helper that parses timeBounds from a raw XDR string and returns true if maxTime has passed; returns false on any parse error so a malformed XDR never blocks approval - withdrawals.js: call isXdrExpired before platform signs; return HTTP 410 with a clear re-request message if expired - WithdrawalsSection.jsx: track expired row IDs; detect 410 responses in runAction; show amber warning banner and Expired status label; hide admin approve+submit button for expired rows - withdrawals.test.js: add isXdrExpired stub; new test verifies 410 on expired XDR --- backend/src/routes/withdrawals.js | 9 +++++ backend/src/routes/withdrawals.test.js | 36 +++++++++++++++++++ backend/src/services/stellarService.js | 17 ++++++++- .../src/components/WithdrawalsSection.jsx | 20 ++++++++--- 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/backend/src/routes/withdrawals.js b/backend/src/routes/withdrawals.js index 9ef8454..f7f492c 100644 --- a/backend/src/routes/withdrawals.js +++ b/backend/src/routes/withdrawals.js @@ -10,6 +10,7 @@ const { signTransactionXdr, signatureCountFromXdr, submitSignedWithdrawal, + isXdrExpired, PLATFORM_PUBLIC_KEY, } = require('../services/stellarService'); const { @@ -381,6 +382,14 @@ const platformApproveHandler = async (req, res) => { return res.status(409).json({ error: 'Platform already approved this withdrawal' }); } + // Check whether the XDR time bounds have already elapsed before adding our signature. + // If expired, tell the creator to re-request so a fresh XDR is built. + if (isXdrExpired(requestRow.unsigned_xdr)) { + return res.status(410).json({ + error: 'Withdrawal XDR has expired. The creator must cancel and submit a new withdrawal request.', + }); + } + const signedXdr = signTransactionXdr({ xdr: requestRow.unsigned_xdr, signerSecret: process.env.PLATFORM_SECRET_KEY, diff --git a/backend/src/routes/withdrawals.test.js b/backend/src/routes/withdrawals.test.js index 967dd07..553eb15 100644 --- a/backend/src/routes/withdrawals.test.js +++ b/backend/src/routes/withdrawals.test.js @@ -14,6 +14,8 @@ function buildApp({ queryImpl, stellarImpl, userId = 'creator-1', role = 'creato signTransactionXdr: () => 'xdr-signed', signatureCountFromXdr: () => 2, submitSignedWithdrawal: async () => 'tx-hash', + // Default: XDR is not expired. Override in specific tests via stellarImpl. + isXdrExpired: () => false, PLATFORM_PUBLIC_KEY: 'GPLATFORM', ...stellarImpl, }; @@ -456,3 +458,37 @@ test('POST /api/withdrawals/:id/approve/platform logs failure when Stellar rejec cleanup(); assert.equal(response.status, 502); }); + +test('POST /api/withdrawals/:id/approve/platform returns 410 when XDR time bounds are expired', async () => { + const { app, cleanup } = buildApp({ + role: 'admin', + queryImpl: async (text) => { + if (text.includes('SELECT wr.*, c.status')) { + return { + rows: [{ + id: 'w-expired', + status: 'pending', + creator_signed: true, + platform_signed: false, + unsigned_xdr: 'xdr-expired', + campaign_status: 'active', + }], + }; + } + return { rows: [] }; + }, + stellarImpl: { + // Simulate an expired XDR — isXdrExpired returns true + isXdrExpired: () => true, + }, + }); + + const response = await request(app) + .post('/api/withdrawals/w-expired/approve/platform') + .set('Authorization', 'Bearer token') + .send({}); + + cleanup(); + assert.equal(response.status, 410); + assert.match(response.body.error, /expired/i); +}); diff --git a/backend/src/services/stellarService.js b/backend/src/services/stellarService.js index 38ef3dc..5b4da55 100644 --- a/backend/src/services/stellarService.js +++ b/backend/src/services/stellarService.js @@ -438,7 +438,7 @@ async function buildWithdrawalTransaction({ amount: String(amount), }) ) - .setTimeout(300) // 5 minutes for both parties to sign + .setTimeout(60 * 60 * 24 * 7) // 7 days — platform approver may not be available immediately .build(); return tx.toXDR(); @@ -464,6 +464,20 @@ function signatureCountFromXdr(xdr) { return tx.signatures.length; } +/** + * Returns true if the XDR transaction's maxTime has already passed. + * Returns false if the XDR cannot be parsed or has no time bounds set. + */ +function isXdrExpired(xdr) { + try { + const tx = TransactionBuilder.fromXDR(xdr, networkPassphrase); + const { timeBounds } = tx; + return !!(timeBounds && Math.floor(Date.now() / 1000) > Number(timeBounds.maxTime)); + } catch { + return false; + } +} + async function submitPreparedTransaction(xdr) { const tx = TransactionBuilder.fromXDR(xdr, networkPassphrase); const result = await server.submitTransaction(tx); @@ -571,6 +585,7 @@ module.exports = { getAccountMultisigConfig, signTransactionXdr, signatureCountFromXdr, + isXdrExpired, submitSignedWithdrawal, recoverWalletFromSecret, getWalletTransactionHistory, diff --git a/frontend/src/components/WithdrawalsSection.jsx b/frontend/src/components/WithdrawalsSection.jsx index 73461c7..3117b1b 100644 --- a/frontend/src/components/WithdrawalsSection.jsx +++ b/frontend/src/components/WithdrawalsSection.jsx @@ -5,7 +5,8 @@ import { stellarExpertTxUrl } from '../config/stellar'; const ELIGIBLE = ['active', 'funded']; -function statusLabel(row) { +function statusLabel(row, isExpired) { + if (isExpired) return 'Expired — please re-request'; if (row.status === 'pending') { if (!row.creator_signed) return 'Awaiting creator signature'; if (!row.platform_signed) return 'Awaiting platform release'; @@ -27,6 +28,7 @@ export default function WithdrawalsSection({ campaign, milestones = [], user, to const [eventsById, setEventsById] = useState({}); const [openAudit, setOpenAudit] = useState(null); const [milestoneForms, setMilestoneForms] = useState({}); + const [expiredIds, setExpiredIds] = useState(() => new Set()); const isCreator = user?.id && campaign.creator_id === user.id; const isAdmin = user?.role === 'admin'; @@ -133,7 +135,12 @@ export default function WithdrawalsSection({ campaign, milestones = [], user, to await refresh(); onReleased?.(); } catch (err) { - setError(err.message || 'Action failed.'); + if (err.status === 410) { + // XDR has expired — mark this row so the UI can prompt the creator to re-request + setExpiredIds((prev) => new Set([...prev, id])); + } else { + setError(err.message || 'Action failed.'); + } } finally { setBusyId(null); } @@ -339,7 +346,12 @@ export default function WithdrawalsSection({ campaign, milestones = [], user, to {Number(row.amount).toLocaleString()} {campaign.asset_type} →{' '} {row.destination_key.slice(0, 6)}…{row.destination_key.slice(-4)} -
{statusLabel(row)}
+
{statusLabel(row, expiredIds.has(row.id))}
+ {expiredIds.has(row.id) && ( +
+ This withdrawal XDR has expired. Please cancel this request and submit a new one. +
+ )} {row.denial_reason && (
{row.denial_reason} @@ -412,7 +424,7 @@ export default function WithdrawalsSection({ campaign, milestones = [], user, to )} )} - {row.status === 'pending' && row.creator_signed && !row.platform_signed && cap.can_approve_platform && ( + {row.status === 'pending' && row.creator_signed && !row.platform_signed && cap.can_approve_platform && !expiredIds.has(row.id) && ( <> + +
+ )}
{milestone.creator_email}
-

Platform Fees Collected

-

${stats.platform_fees_collected}

- - - -

Campaign Management

-
- - - - - - - - - - - {campaigns.map(c => ( - - - - - - - ))} - -
TitleCreatorStatusAction
{c.title}{c.creator_email}{c.status} - -
-
- -

Milestone Reviews

- {milestones.length === 0 ? ( -

No milestone activity yet.

- ) : ( -
- {milestones.map((milestone) => ( -
-
-
- {milestone.title} -
- {milestone.campaign_title} · {milestone.release_percentage}% · {milestone.status} -
-
- {milestone.description || 'No description provided.'}
- {milestone.evidence_url && ( -
- Evidence:{' '} - - Open link - -
- )} - {milestone.destination_key && ( -
- Destination: {milestone.destination_key} -
- )} - {milestone.review_note && ( -
- Note: {milestone.review_note} -
- )} - {milestone.status !== 'released' && ( -
- - -
- )}
))} -
{milestone.creator_email}
-
-
- {milestone.description || 'No description provided.'} -
{milestone.evidence_url && (
Evidence:{' '}