Skip to content
Open
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
9 changes: 9 additions & 0 deletions backend/src/routes/withdrawals.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const {
signTransactionXdr,
signatureCountFromXdr,
submitSignedWithdrawal,
isXdrExpired,
PLATFORM_PUBLIC_KEY,
} = require('../services/stellarService');
const {
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions backend/src/routes/withdrawals.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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);
});
17 changes: 16 additions & 1 deletion backend/src/services/stellarService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -571,6 +585,7 @@ module.exports = {
getAccountMultisigConfig,
signTransactionXdr,
signatureCountFromXdr,
isXdrExpired,
submitSignedWithdrawal,
recoverWalletFromSecret,
getWalletTransactionHistory,
Expand Down
20 changes: 16 additions & 4 deletions frontend/src/components/WithdrawalsSection.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -339,7 +346,12 @@ export default function WithdrawalsSection({ campaign, milestones = [], user, to
{Number(row.amount).toLocaleString()} {campaign.asset_type} →{' '}
<code style={styles.code}>{row.destination_key.slice(0, 6)}…{row.destination_key.slice(-4)}</code>
</div>
<div style={styles.meta}>{statusLabel(row)}</div>
<div style={styles.meta}>{statusLabel(row, expiredIds.has(row.id))}</div>
{expiredIds.has(row.id) && (
<div className="alert alert--warning" style={{ marginTop: '0.5rem', fontSize: '0.8rem' }} role="alert">
This withdrawal XDR has expired. Please cancel this request and submit a new one.
</div>
)}
{row.denial_reason && (
<div className="alert alert--error" style={{ marginTop: '0.5rem', fontSize: '0.8rem' }}>
{row.denial_reason}
Expand Down Expand Up @@ -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) && (
<>
<button
type="button"
Expand Down
142 changes: 41 additions & 101 deletions frontend/src/pages/AdminDashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -473,112 +473,52 @@ export default function AdminDashboard() {
<div style={{ color:'#666', fontSize:'0.9rem', marginTop:'0.2rem' }}>
{milestone.campaign_title} · {milestone.release_percentage}% · {milestone.status}
</div>
<div style={{ marginTop:'0.6rem', color:'#444', lineHeight:1.5 }}>
{milestone.description || 'No description provided.'}
</div>
{milestone.evidence_url && (
<div style={{ marginTop:'0.6rem', fontSize:'0.88rem' }}>
Evidence:{' '}
<a href={milestone.evidence_url} target="_blank" rel="noopener noreferrer" style={{ color:'#7c3aed', fontWeight:600 }}>
Open link
</a>
</div>
)}
{milestone.destination_key && (
<div style={{ marginTop:'0.35rem', fontSize:'0.84rem', color:'#555' }}>
Destination: {milestone.destination_key}
</div>
)}
{milestone.review_note && (
<div style={{ marginTop:'0.6rem', fontSize:'0.84rem', color:'#7c3aed' }}>
Note: {milestone.review_note}
</div>
)}
{milestone.status !== 'released' && (
<div style={{ display:'flex', gap:'0.75rem', flexWrap:'wrap', marginTop:'0.85rem' }}>
<button
type="button"
className="btn-primary"
disabled={busyMilestoneId === milestone.id || !milestone.evidence_url || !milestone.destination_key}
onClick={() => approveMilestone(milestone.id)}
>
{busyMilestoneId === milestone.id ? 'Processing…' : 'Approve & release'}
</button>
<button
type="button"
className="btn-secondary"
disabled={busyMilestoneId === milestone.id}
onClick={() => rejectMilestone(milestone.id)}
>
Reject
</button>
</div>
)}
</div>
<div style={{ color:'#666', fontSize:'0.84rem' }}>{milestone.creator_email}</div>
<h3 style={{fontSize:'1rem', color:'var(--color-text-secondary)'}}>Platform Fees Collected</h3>
<p style={{fontSize:'1.8rem', fontWeight:700}}>${stats.platform_fees_collected}</p>
</div>
</div>

<h2 style={{fontSize:'1.4rem', fontWeight:700, marginBottom:'1rem'}}>Campaign Management</h2>
<div style={{overflowX:'auto', marginBottom:'2.5rem'}}>
<table style={tableStyle}>
<thead>
<tr>
<th style={thStyle}>Title</th>
<th style={thStyle}>Creator</th>
<th style={thStyle}>Status</th>
<th style={thStyle}>Action</th>
</tr>
</thead>
<tbody>
{campaigns.map(c => (
<tr key={c.id}>
<td style={tdStyle}>{c.title}</td>
<td style={tdStyle}>{c.creator_email}</td>
<td style={tdStyle}>{c.status}</td>
<td style={tdStyle}>
<select value={c.status} onChange={(e) => {
api.updateCampaignStatus(c.id, e.target.value, token).then(() => {
setCampaigns(campaigns.map(camp => camp.id === c.id ? {...camp, status: e.target.value} : camp));
});
}} style={{padding:'0.3rem', borderRadius:'4px', border:'1px solid var(--color-border-light)'}}>
<option value="active">Active</option>
<option value="funded">Funded</option>
<option value="in_progress">In progress</option>
<option value="completed">Completed</option>
<option value="closed">Closed</option>
<option value="withdrawn">Withdrawn</option>
<option value="failed">Failed</option>
</select>
</td>
</tr>
))}
</tbody>
</table>
</div>

<h2 style={{fontSize:'1.4rem', fontWeight:700, marginBottom:'1rem'}}>Milestone Reviews</h2>
{milestones.length === 0 ? (
<p style={{ color: 'var(--color-text-hint)', marginBottom: '2rem' }}>No milestone activity yet.</p>
) : (
<div style={{display:'grid', gap:'0.9rem', marginBottom:'2.5rem'}}>
{milestones.map((milestone) => (
<div key={milestone.id} style={{ border:'1px solid var(--color-border-light)', borderRadius:'12px', padding:'1rem', background:'var(--color-bg)' }}>
<div style={{ display:'flex', justifyContent:'space-between', gap:'0.75rem', flexWrap:'wrap' }}>
<div>
<strong>{milestone.title}</strong>
<div style={{ color:'var(--color-text-hint)', fontSize:'0.9rem', marginTop:'0.2rem' }}>
{milestone.campaign_title} · {milestone.release_percentage}% · {milestone.status}
</div>
<div style={{ marginTop:'0.6rem', color:'#444', lineHeight:1.5 }}>
{milestone.description || 'No description provided.'}
</div>
{milestone.evidence_url && (
<div style={{ marginTop:'0.6rem', fontSize:'0.88rem' }}>
Evidence:{' '}
<a href={milestone.evidence_url} target="_blank" rel="noopener noreferrer" style={{ color:'#7c3aed', fontWeight:600 }}>
Open link
</a>
</div>
)}
{milestone.destination_key && (
<div style={{ marginTop:'0.35rem', fontSize:'0.84rem', color:'#555' }}>
Destination: {milestone.destination_key}
</div>
)}
{milestone.review_note && (
<div style={{ marginTop:'0.6rem', fontSize:'0.84rem', color:'#7c3aed' }}>
Note: {milestone.review_note}
</div>
)}
{milestone.status !== 'released' && (
<div style={{ display:'flex', gap:'0.75rem', flexWrap:'wrap', marginTop:'0.85rem' }}>
<button
type="button"
className="btn-primary"
disabled={busyMilestoneId === milestone.id || !milestone.evidence_url || !milestone.destination_key}
onClick={() => approveMilestone(milestone.id)}
>
{busyMilestoneId === milestone.id ? 'Processing…' : 'Approve & release'}
</button>
<button
type="button"
className="btn-secondary"
disabled={busyMilestoneId === milestone.id}
onClick={() => rejectMilestone(milestone.id)}
>
Reject
</button>
</div>
)}
</div>
))}
<div style={{ color:'var(--color-text-hint)', fontSize:'0.84rem' }}>{milestone.creator_email}</div>
</div>
<div style={{ marginTop:'0.6rem', color:'var(--color-text-secondary)', lineHeight:1.5 }}>
{milestone.description || 'No description provided.'}
</div>
{milestone.evidence_url && (
<div style={{ marginTop:'0.6rem', fontSize:'0.88rem' }}>
Evidence:{' '}
Expand Down
Loading