Skip to content

Implement donation status recovery for PENDING/FAILED transactions#34

Open
Just-Bamford wants to merge 2 commits into
OrbitChainLabs:mainfrom
Just-Bamford:fix/donation-status-recovery
Open

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

Conversation

@Just-Bamford

Copy link
Copy Markdown

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
}

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

Labels

None yet

Projects

None yet

1 participant