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 |