Zum Inhalt

Recovery-Mechanismen

Übersicht

Zwei Recovery-Worker laufen alle 2 Minuten und beheben verlorene Transaktions-Bestätigungen:

┌─────────────────────────┐    ┌──────────────────────────┐
│  recoverBuyIntents      │    │  resolvePendingClaims    │
│  (alle 2 Min)           │    │  (alle 2 Min)            │
│                         │    │                          │
│  SIGNED/EXECUTING       │    │  claimed_pending=true    │
│  → CONFIRMED/FAILED     │    │  → claimed=true/cleared  │
└─────────────────────────┘    └──────────────────────────┘

Buy-Intent-Recovery

Datei: workers/recoverBuyIntents.ts (208 Zeilen) Schedule: Alle 2 Minuten via Cron

Problem

Wenn die App eine Buy-Transaktion sendet und die Bestätigung verloren geht (z.B. WebSocket-Disconnect, App-Crash), bleibt der Intent im Status SIGNED oder EXECUTING hängen.

Ablauf

1. Finde "stuck" Intents
   ├── Status: SIGNED oder EXECUTING
   ├── tx_hash vorhanden
   └── Älter als 2 Minuten
2. Prüfe TX-Receipt (Primary + Fallback RPC)
   ├── Kein Receipt → Skip (TX noch pending)
   ├── Receipt.status = 0 → Mark FAILED
   └── Receipt.status = 1 → finalizeBuyIntent()

Query

SELECT * FROM buy_intents
WHERE status IN ('SIGNED', 'EXECUTING')
  AND tx_hash IS NOT NULL
  AND created_at < now() - interval '2 minutes'

Parallelverarbeitung

// recoverBuyIntents.ts:66-91
const MAX_CONCURRENCY = 10;

async function processWithConcurrency(items, fn) {
  const executing = new Set();
  for (const item of items) {
    const p = fn(item).finally(() => executing.delete(p));
    executing.add(p);
    if (executing.size >= MAX_CONCURRENCY) {
      await Promise.race(executing);
    }
  }
  await Promise.all(executing);
}

Dual-RPC-Fallback

// recoverBuyIntents.ts:47-58
const primary = new JsonRpcProvider(RPC_URL);
const fallback = new JsonRpcProvider(FALLBACK_RPC_URL ?? RPC_URL);

async function getReceipt(txHash: string) {
  const receipt = await primary.getTransactionReceipt(txHash);
  if (receipt) return receipt;
  return fallback.getTransactionReceipt(txHash);
}

finalizeBuyIntent

Datei: services/finalizeBuyIntent_v2.ts (294 Zeilen)

Die Finalisierung erstellt aus dem Intent ein vollständiges Ticket:

// finalizeBuyIntent_v2.ts:82-293
async function finalizeBuyIntent(pg, intent, receipt) {
  // 1. Row-Level-Lock
  await pg.query("SELECT * FROM buy_intents WHERE intent_id = $1 FOR UPDATE", [id]);

  // 2. Idempotenz-Check
  if (intent.status === 'CONFIRMED') return; // Bereits finalisiert

  // 3. TicketCreated Event extrahieren
  const event = extractTicketCreatedEvent(receipt);

  // 4. Stake-Split berechnen
  const { feeAmount, potAmount, rolloverAmount, jackpotAmount } = calculateSplit(totalStake);

  // 5. Ticket einfügen
  await pg.query(`INSERT INTO tickets (...) VALUES (...)`, [...]);

  // 6. Tips einfügen
  for (const tip of tips) {
    await pg.query(`INSERT INTO ticket_tips (...) VALUES (...)`, [...]);
  }

  // 7. Intent-Status aktualisieren
  await pg.query(
    `UPDATE buy_intents SET status = 'CONFIRMED', ticket_id = $1 WHERE intent_id = $2`,
    [ticketId, intentId]
  );
}

Stake-Aufteilung

// finalizeBuyIntent_v2.ts:52-72
const FEE_BP = 2750;       // 27.5%
const ROLLOVER_BP = 500;    // 5%
const JACKPOT_BP = 4000;    // 40% vom Pot

feeAmount      = totalStake * 2750 / 10000    // 27.5%
rolloverAmount = totalStake * 500 / 10000      // 5%
potAmount      = totalStake - fee - rollover   // 67.5%
jackpotAmount  = potAmount * 4000 / 10000      // 40% des Pots → jackpotReserveAcc
remainingPot   = potAmount - jackpotAmount     // 60% des Pots → potAcc

Claim-Recovery

Datei: workers/resolvePendingClaims.ts (218 Zeilen) Schedule: Alle 2 Minuten via Cron

Problem

Wenn ein Spieler einen Gewinn claimen will und die TX-Bestätigung verloren geht, bleibt der Claim im Pending-Status.

Ablauf

1. Finde pending Claims
   ├── Winner Claims: claimed_pending=true, claimed=false
   └── Affiliate Claims: claimed_pending=true, claimed=false
2. Prüfe TX-Receipt
   ├── Kein Receipt → Skip (TX noch pending)
   ├── Receipt reverted → Clear pending flags
   └── Receipt success → Trigger Backfill

Winner Claims Query

SELECT DISTINCT pending_tx_hash
FROM settlement_winner_payouts
WHERE claimed_pending = true
  AND pending_tx_hash IS NOT NULL
  AND claimed = false

Backfill-Mechanismus

Datei: workers/backfill_claim_events.ts (248 Zeilen)

Bei erfolgreichen Claims wird ein Backfill ausgelöst, der Events aus der Blockchain liest:

// resolvePendingClaims.ts:106-119
async function resolveTx(txHash, blockNumber) {
  // Backfill mit Block-Range +/-2
  const fromBlock = blockNumber - 2;
  const toBlock = blockNumber + 2;

  await execScript(`backfill_claim_events.ts ${fromBlock} ${toBlock}`);
}

Der Backfill liest PrizeVaultPayout und AffiliateVaultPayout Events:

// backfill_claim_events.ts:90-128
async function markWinnerClaimed(pg, log) {
  await pg.query(
    `UPDATE settlement_winner_payouts
     SET claimed = true,
         claimed_pending = false,
         claim_tx_hash = $1,
         claimed_at = $2
     WHERE player = $3 AND settlement_run_id = $4`,
    [log.txHash, log.timestamp, log.player, log.runId]
  );
}

Revert-Handling

// resolvePendingClaims.ts:42-64
async function clearWinnerPending(pg, txHash) {
  // TX war reverted → Spieler kann erneut claimen
  await pg.query(
    `UPDATE settlement_winner_payouts
     SET claimed_pending = false,
         pending_tx_hash = NULL
     WHERE pending_tx_hash = $1`,
    [txHash]
  );

  // Event loggen
  await logClaimEvent(pg, {
    event_type: 'CLAIM_TX_REVERTED',
    scope: 'winner',
    tx_hash: txHash
  });
}

Claim-Event-Logging

Datei: lib/claimEventLogger.ts (51 Zeilen)

Alle Claim-Aktionen werden in der claim_events-Tabelle protokolliert:

Event-Typ Beschreibung
CLAIM_SIGNATURE_ERROR Signatur-Fehler beim Claim
CLAIM_TX_ERROR TX-Fehler
CLAIM_PENDING_SET Pending-Flag gesetzt
CLAIM_TX_REVERTED TX war reverted
BACKFILL_TRIGGERED Backfill ausgelöst
CLAIM_CONFIRMED Claim bestätigt
BACKFILL_CLAIM_CONFIRMED Via Backfill bestätigt

Bonus-Grant-Recovery

Datei: services/processBonusGrant.ts (235 Zeilen)

Für Auto-Bonus-Vergabe (BonusManager-Contract):

// processBonusGrant.ts:98-110
const MAX_RETRIES = 5;

if (grant.retry_count >= MAX_RETRIES) {
  await markFailed(pg, grant.id, "Max retries exceeded");
  return;
}
Parameter Wert
Max Retries 5
Receipt-Timeout 3 x 10s Polls
Idempotent Error AUTO_BONUS_ALREADY_GRANTED → Erfolg

Status-Übergänge

Buy-Intent Lifecycle

CREATED → SIGNED → EXECUTING → CONFIRMED
                            ↘   FAILED

Claim Lifecycle

           ┌── claimed_pending=true ──┐
           │                          │
  unclaimed ──────────────────────── claimed
           │                          ▲
           └── pending cleared ───────┘
                (bei Revert)     (bei Backfill)