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)}
-
${stats.platform_fees_collected}
- - - -| Title | -Creator | -Status | -Action | -
|---|---|---|---|
| {c.title} | -{c.creator_email} | -{c.status} | -- - | -
No milestone activity yet.
- ) : ( -