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
106 changes: 106 additions & 0 deletions docs/donation-recovery-mechanism.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Donation Recovery Mechanism

## Overview

This document describes the donation recovery mechanism implemented to handle cases where donation transactions are initially marked as `PENDING` or `FAILED` due to temporary RPC outages or network issues, but actually succeeded on-chain.

## Problem Statement

Previously, `DonationsService.createDonation` would return cached donation records immediately when a duplicate `txHash` was detected, without re-verifying the on-chain status. This caused issues when:

1. A transaction was stored as `FAILED` during a Stellar RPC outage, but actually succeeded on-chain
2. Users retrying the same transaction would continue to see the stale `FAILED` status
3. The only remediation was manual database intervention

## Solution

### 1. Idempotent Re-Verification

When a donation with an existing `txHash` is submitted:

- If the status is `PENDING` or `FAILED`
- And the donation was created within the last **30 seconds** (idempotency window)
- The system re-verifies the transaction on-chain using `stellarTxs.verifyDonationTransaction`

### 2. Status Recovery

If re-verification succeeds:

- The donation status is updated to `CONFIRMED`
- The `confirmedAt` timestamp is set
- Campaign statistics are recalculated
- A `recovered: true` flag is included in the response

### 3. Idempotency Window

The 30-second window prevents:

- Excessive Horizon/RPC calls during retry storms
- Resource exhaustion from repeated verification attempts
- Unnecessary re-verification of legitimately failed transactions

After the window expires, the cached status is returned as-is.

### 4. Response Flag

The `DonationResponseDto` now includes an optional `recovered` boolean:

- `recovered: true` - Status was successfully recovered from PENDING/FAILED
- `recovered: false` - Cached donation returned without re-verification
- `recovered: undefined` - Response from other endpoints that don't use this flag

## Implementation Details

### Code Changes

1. **`dto/donation.dto.ts`**: Added `recovered?: boolean` to `DonationResponseDto`
2. **`donations.service.ts`**:
- Enhanced `createDonation` to check status and timing before returning cached records
- Added `retryVerifyDonation` private method to handle re-verification logic
- Set `recovered` flag appropriately in all response paths

### Idempotency Window Configuration

```typescript
const idempotencyWindowMs = 30_000; // 30 seconds
```

This can be adjusted based on:

- Average wallet client retry behavior
- Network conditions
- RPC rate limits

## Usage Example

### Client Behavior

```typescript
// User submits donation
const response = await createDonation({ txHash: "abc123", ... });

if (response.donation.status === 'FAILED' && !response.donation.recovered) {
// Legitimately failed - show error
showError("Transaction failed");
} else if (response.donation.status === 'CONFIRMED' && response.donation.recovered) {
// Recovered from PENDING/FAILED - show success with recovery notice
showSuccess("Transaction confirmed (recovered)");
} else if (response.donation.status === 'CONFIRMED') {
// Normal confirmation
showSuccess("Transaction confirmed");
}
```

## Benefits

1. **Automatic Recovery**: Users see correct status without manual intervention
2. **Client Transparency**: The `recovered` flag lets clients distinguish replays from recoveries
3. **Rate Limiting**: Idempotency window prevents excessive on-chain calls
4. **Audit Trail**: Recovery events can be logged/monitored via the flag

## Future Enhancements

- Make idempotency window configurable via environment variable
- Add metrics/logging for recovery events
- Consider extending recovery to other status transitions (e.g., REFUNDED)
- Implement exponential backoff for verification retries
6 changes: 5 additions & 1 deletion src/campaigns/campaigns.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,11 @@ function parseMilestoneTargetAmount(targetAmount?: string) {
const raw = targetAmount?.trim();
const amount = raw ? Number(raw) : Number.NaN;

if (!raw || !Number.isFinite(amount) || amount < MIN_MILESTONE_TARGET_AMOUNT) {
if (
!raw ||
!Number.isFinite(amount) ||
amount < MIN_MILESTONE_TARGET_AMOUNT
) {
throw new BadRequestException(
`milestone targetAmount is required and must be at least ${MIN_MILESTONE_TARGET_AMOUNT}`,
);
Expand Down
92 changes: 92 additions & 0 deletions src/donations/donations.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,44 @@ export class DonationsService {
where: { txHash: dto.txHash },
});
if (existing) {
// Re-verify PENDING or FAILED donations if within idempotency window
if (existing.status === 'PENDING' || existing.status === 'FAILED') {
const idempotencyWindowMs = 30_000; // 30 seconds
const timeSinceCreation = Date.now() - existing.createdAt.getTime();

if (timeSinceCreation <= idempotencyWindowMs) {
const recovered = await this.retryVerifyDonation(existing, dto);
if (recovered) {
// Fetch the updated donation
const updated = await this.prisma.donation.findUnique({
where: { txHash: dto.txHash },
});
if (updated) {
return {
donation: {
id: updated.id,
amount: updated.amount.toString(),
assetCode: updated.assetCode,
txHash: updated.txHash,
status: updated.status,
donorId: updated.donorId,
campaignId: updated.campaignId,
tipAmount: updated.tipAmount?.toString() || null,
tipAsset: updated.tipAsset || null,
tipId: updated.tipId,
donatedAt: updated.donatedAt,
confirmedAt: updated.confirmedAt,
createdAt: updated.createdAt,
recovered: true,
},
tip: null,
};
}
}
}
}

// Return cached donation (not recovered or outside window)
return {
donation: {
id: existing.id,
Expand All @@ -60,6 +98,7 @@ export class DonationsService {
donatedAt: existing.donatedAt,
confirmedAt: existing.confirmedAt,
createdAt: existing.createdAt,
recovered: false,
},
tip: null,
};
Expand Down Expand Up @@ -123,11 +162,64 @@ export class DonationsService {
donatedAt: created.donatedAt,
confirmedAt: created.confirmedAt,
createdAt: created.createdAt,
recovered: false,
},
tip: null,
};
}

/**
* Retry verification for PENDING or FAILED donations
* Re-checks the transaction on-chain and updates the status if successful
* @returns true if the donation was recovered (status changed to CONFIRMED)
*/
private async retryVerifyDonation(
existing: any,
dto: CreateDonationDto,
): Promise<boolean> {
try {
const campaign = await this.prisma.campaign.findUnique({
where: { id: dto.campaignId },
});

if (!campaign || !campaign.contractId) {
return false;
}

const requestedAsset = parseAsset(
dto.assetCode || 'XLM',
dto.assetIssuer,
);
const acceptedAssets = coerceAcceptedAssets(campaign.acceptedAssets);

// Re-verify the transaction on-chain
await this.stellarTxs.verifyDonationTransaction({
txHash: dto.txHash!,
destination: campaign.contractId,
amount: dto.amount,
asset: requestedAsset,
acceptedAssets,
});

// If verification succeeds, update the donation status
await this.prisma.donation.update({
where: { txHash: dto.txHash },
data: {
status: 'CONFIRMED',
confirmedAt: new Date(),
},
});

// Recalculate campaign stats since we confirmed a previously failed/pending donation
await this.campaigns.recalculateCampaignStats(campaign.id);

return true;
} catch (error) {
// Verification failed, keep existing status
return false;
}
}

/** Get all donations for a user ordered by most recent first */
async findAll(userId: string) {
return this.prisma.donation.findMany({
Expand Down
2 changes: 2 additions & 0 deletions src/donations/dto/donation.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export class DonationResponseDto {
donatedAt: Date;
confirmedAt: Date | null;
createdAt: Date;
/** Flag indicating if the donation status was recovered from PENDING/FAILED on re-verification */
recovered?: boolean;
}

export class PlatformTipResponseDto {
Expand Down
12 changes: 10 additions & 2 deletions src/milestones/milestones.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,16 @@ describe('MilestonesService', () => {
describe('getCampaignFundReleaseStats', () => {
it('aggregates counts and sums grouped by status', async () => {
prisma.fundRelease.groupBy.mockResolvedValueOnce([
{ status: 'PENDING', _count: 2, _sum: { amount: { toString: () => '300' } } },
{ status: 'RELEASED', _count: 1, _sum: { amount: { toString: () => '700' } } },
{
status: 'PENDING',
_count: 2,
_sum: { amount: { toString: () => '300' } },
},
{
status: 'RELEASED',
_count: 1,
_sum: { amount: { toString: () => '700' } },
},
]);

const result = await service.getCampaignFundReleaseStats(CAMPAIGN_ID);
Expand Down