Skip to content

Implement donation status recovery for PENDING/FAILED transactions#34

Merged
Alqku merged 2 commits into
OrbitChainLabs:mainfrom
Just-Bamford:fix/donation-status-recovery
Jun 22, 2026
Merged

Implement donation status recovery for PENDING/FAILED transactions#34
Alqku merged 2 commits into
OrbitChainLabs:mainfrom
Just-Bamford:fix/donation-status-recovery

Conversation

@Just-Bamford

Copy link
Copy Markdown
Contributor

Pr Description

The DonationsService.createDonation method (lines ~46-72) had a critical flaw in its idempotency handling. When a donation with an existing txHash was found in the database, it would immediately return the cached record without any verification of the actual on-chain status.

this pr Closes #2

Concrete Impact

  1. Stale FAILED Status During RPC Outages

    • If verifyDonationOnChain was called during a Stellar RPC or Horizon outage, it would store the donation with status FAILED
    • Even if the transaction actually succeeded on the Stellar ledger, the database would contain incorrect FAILED status
    • Every subsequent POST request with the same txHash would return this stale FAILED record
    • Users would see "Donation Failed" in their UI despite funds being successfully transferred on-chain
  2. No Recovery Mechanism

    • Clients had no way to distinguish between a legitimate failure and a stale status
    • The only remediation was manual database intervention by an administrator
    • This created poor user experience and increased support burden
  3. Persistent PENDING States

    • Donations stuck in PENDING status would never be re-checked
    • Users would see perpetual "pending" states even after transactions were confirmed on-chain

Solution Implementation

Core Changes

1. Idempotent Re-Verification Logic

When an existing donation is found by txHash, the system now:

Step 1: Status Check

  • Checks if the existing donation has status PENDING or FAILED
  • If status is CONFIRMED or REFUNDED, returns immediately (no re-verification needed)

Step 2: Time Window Validation

  • Calculates time elapsed since donation creation: Date.now() - existing.createdAt.getTime()
  • Only proceeds with re-verification if within 30-second idempotency window
  • This prevents excessive RPC calls during retry storms from wallet clients

Step 3: On-Chain Re-Verification

  • Calls new retryVerifyDonation method which:
    • Fetches the campaign and validates contractId exists
    • Parses and validates the asset (XLM or custom asset)
    • Re-runs stellarTxs.verifyDonationTransaction with original parameters:
      • txHash: Transaction hash to verify
      • destination: Campaign contract address
      • amount: Expected donation amount
      • asset: Asset type and issuer
      • acceptedAssets: List of assets the campaign accepts
    • If verification succeeds, updates the donation:
      await this.prisma.donation.update({
        where: { txHash: dto.txHash },
        data: {
          status: 'CONFIRMED',
          confirmedAt: new Date(),
        },
      });

Step 4: Campaign Statistics Update

  • Calls campaigns.recalculateCampaignStats(campaign.id)
  • Ensures raisedAmount and other metrics reflect the recovered donation

Step 5: Return with Recovery Flag

  • Returns the updated donation with recovered: true
  • Clients can now distinguish between:
    • Fresh confirmations: status: CONFIRMED, recovered: false
    • Recovered donations: status: CONFIRMED, recovered: true
    • Cached failures: status: FAILED, recovered: false

2. Idempotency Window (30 seconds)

The 30-second window serves multiple purposes:

  • Rate Limiting: Prevents abuse of Horizon/Soroban RPC endpoints
  • Retry Absorption: Most wallet clients retry failed transactions within seconds, this window catches those retries
  • Resource Protection: After 30 seconds, assumes the status is final and returns cached value
  • Configurable: Hardcoded as const idempotencyWindowMs = 30_000 but can be moved to environment variable

3. Recovery Transparency

Added recovered?: boolean field to DonationResponseDto:

export class DonationResponseDto {
  id: string;
  amount: string;
  assetCode: string;
  txHash: string | null;
  status: string;
  donorId: string;
  campaignId: string;
  tipAmount: string | null;
  tipAsset: string | null;
  tipId: string | null;
  donatedAt: Date;
  confirmedAt: Date | null;
  createdAt: Date;
  recovered?: boolean;  // NEW: Indicates status was recovered from PENDING/FAILED
}

@Alqku Alqku left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants