diff --git a/docs/donation-recovery-mechanism.md b/docs/donation-recovery-mechanism.md new file mode 100644 index 0000000..ad34676 --- /dev/null +++ b/docs/donation-recovery-mechanism.md @@ -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 diff --git a/src/campaigns/campaigns.service.ts b/src/campaigns/campaigns.service.ts index 04b14a0..208054d 100644 --- a/src/campaigns/campaigns.service.ts +++ b/src/campaigns/campaigns.service.ts @@ -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}`, ); diff --git a/src/donations/donations.service.ts b/src/donations/donations.service.ts index a0d2c6f..9f7d52a 100644 --- a/src/donations/donations.service.ts +++ b/src/donations/donations.service.ts @@ -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, @@ -60,6 +98,7 @@ export class DonationsService { donatedAt: existing.donatedAt, confirmedAt: existing.confirmedAt, createdAt: existing.createdAt, + recovered: false, }, tip: null, }; @@ -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 { + 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({ diff --git a/src/donations/dto/donation.dto.ts b/src/donations/dto/donation.dto.ts index 3bbc4d6..86c8920 100644 --- a/src/donations/dto/donation.dto.ts +++ b/src/donations/dto/donation.dto.ts @@ -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 { diff --git a/src/milestones/milestones.service.spec.ts b/src/milestones/milestones.service.spec.ts index 5f988fe..baebb86 100644 --- a/src/milestones/milestones.service.spec.ts +++ b/src/milestones/milestones.service.spec.ts @@ -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);